From f696ac9b572cb77592ae2bcb586495126bc26759 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:14:23 -0700 Subject: [PATCH 01/25] Python: A2AAgent defaults name/description from AgentCard (#4661) * Python: A2AAgent defaults name/description from AgentCard When an AgentCard is provided but name/description are not explicitly set, A2AAgent now falls back to agent_card.name and agent_card.description. This avoids redundant duplication when constructing A2AAgent instances, especially in GroupChat orchestrations where name and description are essential for routing decisions. Explicit values still take precedence over card values. Fixes #4630 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use 'is None' checks instead of truthiness for name/description fallback Ensures explicitly provided empty strings are not overridden by agent_card values. Adds test for the empty string edge case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 12 ++++- python/packages/a2a/tests/test_a2a_agent.py | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 31fac386b3..54441ff2b7 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -114,9 +114,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): """Initialize the A2AAgent. Keyword Args: - name: The name of the agent. + name: The name of the agent. Defaults to agent_card.name if agent_card is provided. id: The unique identifier for the agent, will be created automatically if not provided. - description: A brief description of the agent's purpose. + description: A brief description of the agent's purpose. Defaults to agent_card.description + if agent_card is provided. agent_card: The agent card for the agent. url: The URL for the A2A server. client: The A2A client for the agent. @@ -127,6 +128,13 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): 10.0s write, 5.0s pool - optimized for A2A operations). kwargs: any additional properties, passed to BaseAgent. """ + # Default name/description from agent_card when not explicitly provided + if agent_card is not None: + if name is None: + name = agent_card.name + if description is None: + description = agent_card.description + super().__init__(id=id, name=name, description=description, **kwargs) self._http_client: httpx.AsyncClient | None = http_client self._timeout_config = self._create_timeout_config(timeout) diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 61123df5ab..ce7bb42a48 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -145,6 +145,54 @@ def test_a2a_agent_initialization_with_client(mock_a2a_client: MockA2AClient) -> assert agent.client == mock_a2a_client +def test_a2a_agent_defaults_name_description_from_agent_card(mock_a2a_client: MockA2AClient) -> None: + """Test A2AAgent defaults name and description from agent_card when not explicitly provided.""" + mock_card = MagicMock(spec=AgentCard) + mock_card.name = "Card Agent Name" + mock_card.description = "Card agent description" + + agent = A2AAgent(agent_card=mock_card, client=mock_a2a_client, http_client=None) + + assert agent.name == "Card Agent Name" + assert agent.description == "Card agent description" + + +def test_a2a_agent_explicit_name_description_overrides_agent_card(mock_a2a_client: MockA2AClient) -> None: + """Test that explicit name/description take precedence over agent_card values.""" + mock_card = MagicMock(spec=AgentCard) + mock_card.name = "Card Agent Name" + mock_card.description = "Card agent description" + + agent = A2AAgent( + name="Explicit Name", + description="Explicit description", + agent_card=mock_card, + client=mock_a2a_client, + http_client=None, + ) + + assert agent.name == "Explicit Name" + assert agent.description == "Explicit description" + + +def test_a2a_agent_empty_string_name_description_not_overridden(mock_a2a_client: MockA2AClient) -> None: + """Test that explicitly provided empty strings are not overridden by agent_card values.""" + mock_card = MagicMock(spec=AgentCard) + mock_card.name = "Card Agent Name" + mock_card.description = "Card agent description" + + agent = A2AAgent( + name="", + description="", + agent_card=mock_card, + client=mock_a2a_client, + http_client=None, + ) + + assert agent.name == "" + assert agent.description == "" + + def test_a2a_agent_initialization_without_client_raises_error() -> None: """Test A2AAgent initialization without client or URL raises ValueError.""" with raises(ValueError, match="Either agent_card or url must be provided"): @@ -561,6 +609,8 @@ def test_transport_negotiation_both_fail() -> None: # Create a mock agent card mock_agent_card = MagicMock(spec=AgentCard) mock_agent_card.url = "http://test-agent.example.com" + mock_agent_card.name = "Test Agent" + mock_agent_card.description = "A test agent" # Mock the factory to simulate both primary and fallback failures mock_factory = MagicMock() From 84bae0f42a041d88280f72567919fb57e11df760 Mon Sep 17 00:00:00 2001 From: Chinedum Echeta <60179183+cecheta@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:17:24 +0000 Subject: [PATCH 02/25] Python: Fix type hint for `Case` and `Default` (#3985) * Fix type hint for `Case` and `Default` * Add test --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../core/agent_framework/_workflows/_edge.py | 5 ++-- .../tests/workflow/test_workflow_builder.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_edge.py b/python/packages/core/agent_framework/_workflows/_edge.py index 02544ad3df..b9dbd266ec 100644 --- a/python/packages/core/agent_framework/_workflows/_edge.py +++ b/python/packages/core/agent_framework/_workflows/_edge.py @@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field from typing import Any, ClassVar, TypeAlias, TypeVar +from .._agents import SupportsAgentRun from ._const import INTERNAL_SOURCE_ID from ._executor import Executor from ._model_utils import DictConvertible, encode_value @@ -264,7 +265,7 @@ class Case: """ condition: Callable[[Any], bool] - target: Executor | str + target: Executor | SupportsAgentRun @dataclass @@ -287,7 +288,7 @@ class Default: assert fallback.target.id == "dead_letter" """ - target: Executor | str + target: Executor | SupportsAgentRun @dataclass(init=False) diff --git a/python/packages/core/tests/workflow/test_workflow_builder.py b/python/packages/core/tests/workflow/test_workflow_builder.py index 3a7b719530..29a4bf0292 100644 --- a/python/packages/core/tests/workflow/test_workflow_builder.py +++ b/python/packages/core/tests/workflow/test_workflow_builder.py @@ -13,6 +13,8 @@ from agent_framework import ( AgentRunInputs, AgentSession, BaseAgent, + Case, + Default, Executor, Message, ResponseStream, @@ -223,6 +225,29 @@ def test_add_edge_with_condition(): assert "Target" in workflow.executors +def test_switch_case_with_agents(): + """Test add_switch_case_edge_group with Case and Default edges using agents.""" + router = DummyAgent(id="router_agent", name="router") + handler = DummyAgent(id="handler", name="handler") + fallback = DummyAgent(id="fallback_agent", name="fallback") + + workflow = ( + WorkflowBuilder(start_executor=router) + .add_switch_case_edge_group( + router, + [ + Case(condition=lambda _: True, target=handler), + Default(target=fallback), + ], + ) + .build() + ) + + # All three agents should be AgentExecutor wrappers + agent_executors = [e for e in workflow.executors.values() if isinstance(e, AgentExecutor)] + assert len(agent_executors) == 3 + + # region with_output_from tests From b7990908fe046c8b19da74406d5a009ba4d9fa52 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 13 Mar 2026 09:22:56 +0100 Subject: [PATCH 03/25] fix duplicate names between supplied tools and mcp servers (#4649) --- .../_orchestration/_tooling.py | 34 ++--- .../ag-ui/tests/ag_ui/test_tooling.py | 36 +++-- .../packages/core/agent_framework/_agents.py | 60 ++++---- python/packages/core/agent_framework/_mcp.py | 74 +++++++-- .../packages/core/agent_framework/_tools.py | 62 +++++++- .../packages/core/tests/core/test_agents.py | 143 +++++++++++++++--- python/packages/core/tests/core/test_mcp.py | 75 +++++++++ .../skills/script_approval/script_approval.py | 2 +- 8 files changed, 385 insertions(+), 101 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py index 442138649a..585bcb5c3e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any from agent_framework import BaseChatClient +from agent_framework._tools import _append_unique_tools # pyright: ignore[reportPrivateUsage] if TYPE_CHECKING: from agent_framework import SupportsAgentRun @@ -22,7 +23,7 @@ def _collect_mcp_tool_functions(mcp_tools: list[Any]) -> list[Any]: mcp_tools: List of MCP tool instances. Returns: - List of functions from connected MCP tools. + Functions from connected MCP tools. """ functions: list[Any] = [] for mcp_tool in mcp_tools: @@ -56,7 +57,11 @@ def collect_server_tools(agent: SupportsAgentRun) -> list[Any]: # Include functions from connected MCP tools (only available on Agent) mcp_tools = getattr(agent, "mcp_tools", None) if mcp_tools: - server_tools.extend(_collect_mcp_tool_functions(mcp_tools)) + _append_unique_tools( + server_tools, + _collect_mcp_tool_functions(mcp_tools), + duplicate_error_message="Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool.", + ) logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools") for tool in server_tools: @@ -109,26 +114,13 @@ def merge_tools(server_tools: list[Any], client_tools: list[Any] | None) -> list logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)") return None - server_tool_names = {getattr(tool, "name", None) for tool in server_tools} - unique_client_tools = [tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names] - - if not unique_client_tools: - # Same check: must pass server tools if any require approval - if server_tools and _has_approval_tools(server_tools): - logger.info( - f"[TOOLS] Client tools duplicate server but server has approval tools - " - f"passing {len(server_tools)} server tools for approval mode" - ) - return server_tools - logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter") - return None - - combined_tools: list[Any] = [] - if server_tools: - combined_tools.extend(server_tools) - combined_tools.extend(unique_client_tools) + combined_tools = _append_unique_tools( + list(server_tools), + client_tools, + duplicate_error_message="Tool names must be unique.", + ) logger.info( f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools " - f"({len(server_tools)} server + {len(unique_client_tools)} unique client)" + f"({len(server_tools)} server + {len(client_tools)} client)" ) return combined_tools diff --git a/python/packages/ag-ui/tests/ag_ui/test_tooling.py b/python/packages/ag-ui/tests/ag_ui/test_tooling.py index e8567a586d..890ae44541 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_tooling.py +++ b/python/packages/ag-ui/tests/ag_ui/test_tooling.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +import pytest from agent_framework import Agent, tool from agent_framework_ag_ui._orchestration._tooling import ( @@ -20,7 +21,8 @@ class DummyTool: class MockMCPTool: """Mock MCP tool that simulates connected MCP tool with functions.""" - def __init__(self, functions: list[DummyTool], is_connected: bool = True) -> None: + def __init__(self, functions: list[DummyTool], is_connected: bool = True, name: str = "mock-mcp") -> None: + self.name = name self.functions = functions self.is_connected = is_connected @@ -45,11 +47,8 @@ def test_merge_tools_filters_duplicates() -> None: server = [DummyTool("a"), DummyTool("b")] client = [DummyTool("b"), DummyTool("c")] - merged = merge_tools(server, client) - - assert merged is not None - names = [getattr(t, "name", None) for t in merged] - assert names == ["a", "b", "c"] + with pytest.raises(ValueError, match="Duplicate tool name 'b'"): + merge_tools(server, client) def test_register_additional_client_tools_assigns_when_configured() -> None: @@ -131,6 +130,17 @@ def test_collect_server_tools_with_mcp_tools_via_public_property() -> None: assert len(tools) == 2 +def test_collect_server_tools_raises_on_duplicate_agent_and_mcp_tool_names() -> None: + duplicate_tool = DummyTool("regular_tool") + mock_mcp = MockMCPTool([duplicate_tool], is_connected=True, name="docs-mcp") + + agent = _create_chat_agent_with_tool("regular_tool") + agent.mcp_tools = [mock_mcp] + + with pytest.raises(ValueError, match="Duplicate tool name 'regular_tool'"): + collect_server_tools(agent) + + # Additional tests for tooling coverage @@ -176,11 +186,11 @@ def test_merge_tools_no_client_tools() -> None: def test_merge_tools_all_duplicates() -> None: - """merge_tools returns None when all client tools duplicate server tools.""" + """merge_tools raises when client and server tools share a name.""" server = [DummyTool("a"), DummyTool("b")] client = [DummyTool("a"), DummyTool("b")] - result = merge_tools(server, client) - assert result is None + with pytest.raises(ValueError, match="Duplicate tool name 'a'"): + merge_tools(server, client) def test_merge_tools_empty_server() -> None: @@ -208,7 +218,7 @@ def test_merge_tools_with_approval_tools_no_client() -> None: def test_merge_tools_with_approval_tools_all_duplicates() -> None: - """merge_tools returns server tools with approval mode even when client duplicates.""" + """merge_tools raises even when a client tool duplicates an approval-gated server tool.""" class ApprovalTool: def __init__(self, name: str): @@ -217,7 +227,5 @@ def test_merge_tools_with_approval_tools_all_duplicates() -> None: server = [ApprovalTool("write_doc")] client = [DummyTool("write_doc")] # Same name as server - result = merge_tools(server, client) - assert result is not None - assert len(result) == 1 - assert result[0].approval_mode == "always_require" + with pytest.raises(ValueError, match="Duplicate tool name 'write_doc'"): + merge_tools(server, client) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 8f4002e52e..2e6cca7dba 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -29,6 +29,7 @@ from mcp.server.lowlevel import Server from mcp.shared.exceptions import McpError from pydantic import BaseModel, Field, create_model +from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage] from ._clients import BaseChatClient, SupportsChatGetResponse from ._mcp import LOG_LEVEL_MAPPING, MCPTool from ._middleware import AgentMiddlewareLayer, MiddlewareTypes @@ -40,12 +41,7 @@ from ._sessions import ( InMemoryHistoryProvider, SessionContext, ) -from ._tools import ( - FunctionInvocationLayer, - FunctionTool, - ToolTypes, - normalize_tools, -) +from ._tools import FunctionInvocationLayer, FunctionTool, ToolTypes, normalize_tools from ._types import ( AgentResponse, AgentResponseUpdate, @@ -79,6 +75,9 @@ if TYPE_CHECKING: logger = logging.getLogger("agent_framework") +_append_unique_tools = _tool_utils._append_unique_tools # pyright: ignore[reportPrivateUsage] +_get_tool_name = _tool_utils._get_tool_name # pyright: ignore[reportPrivateUsage] + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) OptionsCoT = TypeVar( "OptionsCoT", @@ -88,19 +87,6 @@ OptionsCoT = TypeVar( ) -def _get_tool_name(tool: Any) -> str | None: - """Extract a tool's name from either an object with a .name attribute or a dict tool definition.""" - if isinstance(tool, Mapping): - tool_mapping = cast(Mapping[str, Any], tool) - func = tool_mapping.get("function") - if isinstance(func, Mapping): - func_mapping = cast(Mapping[str, Any], func) - name = func_mapping.get("name") - return name if isinstance(name, str) else None - return None - return getattr(tool, "name", None) - - def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: """Merge two options dicts, with override values taking precedence. @@ -115,11 +101,14 @@ def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, for key, value in override.items(): if value is None: continue - if key == "tools" and result.get("tools"): - # Combine tool lists, avoiding duplicates by name - existing_names = {_get_tool_name(t) for t in result["tools"]} - {None} - unique_new = [t for t in value if _get_tool_name(t) not in existing_names] - result["tools"] = list(result["tools"]) + unique_new + if key == "tools" and (result.get("tools") or value): + base_tools = normalize_tools(result.get("tools")) + override_tools = normalize_tools(value) + result["tools"] = _append_unique_tools( + list(base_tools), + override_tools, + duplicate_error_message="Tool names must be unique.", + ) elif key == "logit_bias" and result.get("logit_bias"): # Merge logit_bias dicts result["logit_bias"] = {**result["logit_bias"], **value} @@ -1117,25 +1106,34 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] ) agent_name = self._get_agent_name() + base_tools = normalize_tools(chat_options.pop("tools", None)) + mcp_duplicate_message = "Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool." # Normalize tools normalized_tools = normalize_tools(tools_) - # Resolve final tool list (runtime provided tools + local MCP server tools) - final_tools: list[FunctionTool | Callable[..., Any] | dict[str, Any] | Any] = [] + # Resolve final tool list (configured tools + runtime provided tools + local MCP server tools) + final_tools = list(base_tools) for tool in normalized_tools: if isinstance(tool, MCPTool): if not tool.is_connected: await self._async_exit_stack.enter_async_context(tool) - final_tools.extend(tool.functions) # type: ignore + _append_unique_tools( + final_tools, + tool.functions, + duplicate_error_message=mcp_duplicate_message, + ) else: - final_tools.append(tool) # type: ignore + _append_unique_tools(final_tools, [tool]) # type: ignore[list-item] - existing_names = {name for t in final_tools if (name := _get_tool_name(t)) is not None} for mcp_server in self.mcp_tools: if not mcp_server.is_connected: await self._async_exit_stack.enter_async_context(mcp_server) - final_tools.extend(f for f in mcp_server.functions if f.name not in existing_names) + _append_unique_tools( + final_tools, + mcp_server.functions, + duplicate_error_message=mcp_duplicate_message, + ) # Merge runtime kwargs into additional_function_arguments so they're available # in function middleware context and tool invocation. @@ -1164,7 +1162,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "store": opts.pop("store", None), "temperature": opts.pop("temperature", None), "tool_choice": opts.pop("tool_choice", None), - "tools": final_tools, + "tools": final_tools or None, "top_p": opts.pop("top_p", None), "user": opts.pop("user", None), **opts, # Remaining options are provider-specific diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 83d896738d..28c5f6db6a 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -26,9 +26,7 @@ from mcp.shared.exceptions import McpError from mcp.shared.session import RequestResponder from opentelemetry import propagate -from ._tools import ( - FunctionTool, -) +from ._tools import FunctionTool from ._types import ( Content, Message, @@ -59,6 +57,8 @@ class MCPSpecificApproval(TypedDict, total=False): logger = logging.getLogger(__name__) +_MCP_REMOTE_NAME_KEY = "_mcp_remote_name" +_MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" # region: Helpers @@ -372,6 +372,20 @@ def _normalize_mcp_name(name: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]", "-", name) +def _build_prefixed_mcp_name( + normalized_name: str, + tool_name_prefix: str | None, +) -> str: + """Build the exposed MCP function name from a normalized name and optional prefix.""" + if not tool_name_prefix: + return normalized_name + normalized_prefix = _normalize_mcp_name(tool_name_prefix).rstrip("_.-") + if not normalized_prefix: + return normalized_name + trimmed_name = normalized_name.lstrip("_.-") + return f"{normalized_prefix}_{trimmed_name}" if trimmed_name else normalized_prefix + + def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, Any] | None: """Inject OpenTelemetry trace context into MCP request _meta via the global propagator(s).""" carrier: dict[str, str] = {} @@ -415,6 +429,7 @@ class MCPTool: description: str | None = None, approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, + tool_name_prefix: str | None = None, load_tools: bool = True, parse_tool_results: Callable[[types.CallToolResult], str | list[Content]] | None = None, load_prompts: bool = True, @@ -435,6 +450,7 @@ class MCPTool: description: A description of the MCP tool. approval_mode: Whether approval is required to run tools. allowed_tools: A collection of tool names to allow. + tool_name_prefix: Optional prefix to prepend to exposed MCP function names. load_tools: Whether to load tools from the MCP server. parse_tool_results: An optional callable with signature ``Callable[[types.CallToolResult], str]`` that overrides the default result @@ -458,6 +474,7 @@ class MCPTool: self.description = description or "" self.approval_mode = approval_mode self.allowed_tools = allowed_tools + self.tool_name_prefix = _normalize_mcp_name(tool_name_prefix).rstrip("_.-") if tool_name_prefix else None self.additional_properties = additional_properties self.load_tools_flag = load_tools self.parse_tool_results = parse_tool_results @@ -480,7 +497,19 @@ class MCPTool: """Get the list of functions that are allowed.""" if not self.allowed_tools: return self._functions - return [func for func in self._functions if func.name in self.allowed_tools] + allowed_names = set(self.allowed_tools) + filtered_functions: list[FunctionTool] = [] + for func in self._functions: + additional_properties = func.additional_properties or {} + normalized_name = additional_properties.get(_MCP_NORMALIZED_NAME_KEY) + remote_name = additional_properties.get(_MCP_REMOTE_NAME_KEY) + if ( + func.name in allowed_names + or (isinstance(normalized_name, str) and normalized_name in allowed_names) + or (isinstance(remote_name, str) and remote_name in allowed_names) + ): + filtered_functions.append(func) + return filtered_functions async def _safe_close_exit_stack(self) -> None: """Safely close the exit stack, handling cross-task boundary errors. @@ -706,12 +735,16 @@ class MCPTool: def _determine_approval_mode( self, - local_name: str, + *candidate_names: str, ) -> Literal["always_require", "never_require"] | None: if isinstance(self.approval_mode, dict): - if (always_require := self.approval_mode.get("always_require_approval")) and local_name in always_require: + if (always_require := self.approval_mode.get("always_require_approval")) and any( + name in always_require for name in candidate_names + ): return "always_require" - if (never_require := self.approval_mode.get("never_require_approval")) and local_name in never_require: + if (never_require := self.approval_mode.get("never_require_approval")) and any( + name in never_require for name in candidate_names + ): return "never_require" return None return self.approval_mode # type: ignore[reportReturnType] @@ -736,20 +769,25 @@ class MCPTool: prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr] for prompt in prompt_list.prompts: - local_name = _normalize_mcp_name(prompt.name) + normalized_name = _normalize_mcp_name(prompt.name) + local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix) # Skip if already loaded if local_name in existing_names: continue input_model = _get_input_model_from_mcp_prompt(prompt) - approval_mode = self._determine_approval_mode(local_name) + approval_mode = self._determine_approval_mode(local_name, normalized_name, prompt.name) func: FunctionTool = FunctionTool( func=partial(self.get_prompt, prompt.name), name=local_name, description=prompt.description or "", approval_mode=approval_mode, input_model=input_model, + additional_properties={ + _MCP_REMOTE_NAME_KEY: prompt.name, + _MCP_NORMALIZED_NAME_KEY: normalized_name, + }, ) self._functions.append(func) existing_names.add(local_name) @@ -779,13 +817,14 @@ class MCPTool: tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr] for tool in tool_list.tools: - local_name = _normalize_mcp_name(tool.name) + normalized_name = _normalize_mcp_name(tool.name) + local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix) # Skip if already loaded if local_name in existing_names: continue - approval_mode = self._determine_approval_mode(local_name) + approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name) # Create FunctionTools out of each tool func: FunctionTool = FunctionTool( func=partial(self.call_tool, tool.name), @@ -793,6 +832,10 @@ class MCPTool: description=tool.description or "", approval_mode=approval_mode, input_model=tool.inputSchema, + additional_properties={ + _MCP_REMOTE_NAME_KEY: tool.name, + _MCP_NORMALIZED_NAME_KEY: normalized_name, + }, ) self._functions.append(func) existing_names.add(local_name) @@ -1055,6 +1098,7 @@ class MCPStdioTool(MCPTool): name: str, command: str, *, + tool_name_prefix: str | None = None, load_tools: bool = True, parse_tool_results: Callable[[types.CallToolResult], str | list[Content]] | None = None, load_prompts: bool = True, @@ -1083,6 +1127,7 @@ class MCPStdioTool(MCPTool): command: The command to run the MCP server. Keyword Args: + tool_name_prefix: Optional prefix to prepend to exposed MCP function names. load_tools: Whether to load tools from the MCP server. parse_tool_results: An optional callable with signature ``Callable[[types.CallToolResult], str]`` that overrides the default result @@ -1119,6 +1164,7 @@ class MCPStdioTool(MCPTool): description=description, approval_mode=approval_mode, allowed_tools=allowed_tools, + tool_name_prefix=tool_name_prefix, additional_properties=additional_properties, session=session, client=client, @@ -1180,6 +1226,7 @@ class MCPStreamableHTTPTool(MCPTool): name: str, url: str, *, + tool_name_prefix: str | None = None, load_tools: bool = True, parse_tool_results: Callable[[types.CallToolResult], str | list[Content]] | None = None, load_prompts: bool = True, @@ -1208,6 +1255,7 @@ class MCPStreamableHTTPTool(MCPTool): url: The URL of the MCP server. Keyword Args: + tool_name_prefix: Optional prefix to prepend to exposed MCP function names. load_tools: Whether to load tools from the MCP server. parse_tool_results: An optional callable with signature ``Callable[[types.CallToolResult], str]`` that overrides the default result @@ -1246,6 +1294,7 @@ class MCPStreamableHTTPTool(MCPTool): description=description, approval_mode=approval_mode, allowed_tools=allowed_tools, + tool_name_prefix=tool_name_prefix, additional_properties=additional_properties, session=session, client=client, @@ -1299,6 +1348,7 @@ class MCPWebsocketTool(MCPTool): name: str, url: str, *, + tool_name_prefix: str | None = None, load_tools: bool = True, parse_tool_results: Callable[[types.CallToolResult], str | list[Content]] | None = None, load_prompts: bool = True, @@ -1325,6 +1375,7 @@ class MCPWebsocketTool(MCPTool): url: The URL of the MCP server. Keyword Args: + tool_name_prefix: Optional prefix to prepend to exposed MCP function names. load_tools: Whether to load tools from the MCP server. parse_tool_results: An optional callable with signature ``Callable[[types.CallToolResult], str]`` that overrides the default result @@ -1358,6 +1409,7 @@ class MCPWebsocketTool(MCPTool): description=description, approval_mode=approval_mode, allowed_tools=allowed_tools, + tool_name_prefix=tool_name_prefix, additional_properties=additional_properties, session=session, client=client, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 090f382f1b..bfb2c7d2cb 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -71,7 +71,6 @@ if TYPE_CHECKING: ResponseStream, ) - ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) else: MCPTool = Any # type: ignore[assignment,misc] @@ -83,9 +82,23 @@ DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 SHELL_TOOL_KIND_VALUE: Final[str] = "shell" ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) + # region Helpers +def _get_tool_name(tool: Any) -> str | None: + """Extract a tool name from a tool object or dict tool definition.""" + if isinstance(tool, Mapping): + func = tool.get("function", None) # type: ignore + if func and isinstance(func, Mapping): + name = func.get("name") # type: ignore + return name if isinstance(name, str) else None + return None + name = getattr(tool, "name", None) + return name if isinstance(name, str) else None + + def _parse_inputs( # pyright: ignore[reportUnusedFunction] inputs: Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None, ) -> list[Content]: @@ -701,6 +714,51 @@ class FunctionTool(SerializationMixin): ToolTypes: TypeAlias = FunctionTool | MCPTool | Mapping[str, Any] | object +def _raise_duplicate_tool_name(tool_name: str, duplicate_error_message: str | None = None) -> None: + message = duplicate_error_message or "Tool names must be unique." + raise ValueError(f"Duplicate tool name '{tool_name}'. {message}") + + +def _append_unique_tools( + existing_tools: list[ToolTypes], + new_tools: Sequence[ToolTypes], + *, + duplicate_error_message: str | None = None, +) -> list[ToolTypes]: + seen_by_name: dict[str, ToolTypes] = {} + for tool_item in existing_tools: + if tool_name := _get_tool_name(tool_item): + seen_by_name[tool_name] = tool_item + + for tool_item in new_tools: + tool_name = _get_tool_name(tool_item) + if tool_name is None: + existing_tools.append(tool_item) + continue + + existing_tool = seen_by_name.get(tool_name) + if existing_tool is None: + seen_by_name[tool_name] = tool_item + existing_tools.append(tool_item) + continue + + if existing_tool is tool_item: + continue + + _raise_duplicate_tool_name(tool_name, duplicate_error_message) + + return existing_tools + + +def _ensure_unique_tool_names( + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]], + *, + duplicate_error_message: str | None = None, +) -> list[ToolTypes]: + normalized_tools = normalize_tools(tools) + return _append_unique_tools([], normalized_tools, duplicate_error_message=duplicate_error_message) + + def normalize_tools( tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, ) -> list[ToolTypes]: @@ -1320,7 +1378,7 @@ def _get_tool_map( tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]], ) -> dict[str, FunctionTool]: tool_list: dict[str, FunctionTool] = {} - for tool_item in normalize_tools(tools): + for tool_item in _ensure_unique_tool_names(tools): if isinstance(tool_item, FunctionTool): tool_list[tool_item.name] = tool_item return tool_list diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index e666e374eb..32c098e51c 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -30,7 +30,7 @@ from agent_framework import ( tool, ) from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name -from agent_framework._mcp import MCPTool +from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_mcp_name class _FixedTokenizer: @@ -41,6 +41,30 @@ class _FixedTokenizer: return self.token_count +class _ConnectedMCPTool(MCPTool): + def __init__(self, name: str, function_names: list[str], *, tool_name_prefix: str | None = None) -> None: + super().__init__(name=name, tool_name_prefix=tool_name_prefix) + self.is_connected = True + self._functions = [] + for function_name in function_names: + normalized_name = _normalize_mcp_name(function_name) + exposed_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix) + self._functions.append( + FunctionTool( + func=lambda value=function_name: value, + name=exposed_name, + description=f"{function_name} from {name}", + additional_properties={ + "_mcp_remote_name": function_name, + "_mcp_normalized_name": normalized_name, + }, + ) + ) + + def get_mcp_client(self) -> contextlib.AbstractAsyncContextManager[Any]: + raise NotImplementedError + + def test_agent_session_type(agent_session: AgentSession) -> None: assert isinstance(agent_session, AgentSession) @@ -953,6 +977,7 @@ async def test_chat_agent_run_with_mcp_tools(client: SupportsChatGetResponse) -> # Create a mock MCP tool mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.name = "mock-mcp" mock_mcp_tool.is_connected = False mock_mcp_tool.functions = [MagicMock()] @@ -970,6 +995,7 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse) """Test agent initialization with local MCP tools.""" # Create a mock MCP tool mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.name = "mock-mcp" mock_mcp_tool.is_connected = False mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool) mock_mcp_tool.__aexit__ = AsyncMock(return_value=None) @@ -1009,6 +1035,7 @@ async def test_mcp_tools_not_duplicated_when_passed_as_runtime_tools( # Create a mock MCP tool that is already connected (simulates turn 2) mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.name = "mock-mcp" mock_mcp_tool.is_connected = True mock_mcp_tool.functions = [mcp_func_a, mcp_func_b] mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool) @@ -1032,6 +1059,77 @@ async def test_mcp_tools_not_duplicated_when_passed_as_runtime_tools( assert len(tool_names) == 3 +async def test_agent_run_raises_on_local_and_agent_mcp_name_conflict(chat_client_base: Any) -> None: + local_tool = FunctionTool( + func=lambda: "local", + name="delete_all_data", + description="Local protected tool", + approval_mode="always_require", + ) + agent = Agent( + client=chat_client_base, + name="TestAgent", + tools=[_ConnectedMCPTool(name="dangerous-mcp", function_names=["delete_all_data"])], + ) + + with raises(ValueError, match="tool_name_prefix"): + await agent.run("hello", tools=[local_tool]) + + +async def test_agent_run_raises_on_runtime_local_and_runtime_mcp_name_conflict(chat_client_base: Any) -> None: + local_tool = FunctionTool( + func=lambda: "local", + name="delete_all_data", + description="Local protected tool", + approval_mode="always_require", + ) + runtime_mcp = _ConnectedMCPTool(name="dangerous-mcp", function_names=["delete_all_data"]) + agent = Agent(client=chat_client_base, name="TestAgent") + + with raises(ValueError, match="tool_name_prefix"): + await agent.run("hello", tools=[local_tool, runtime_mcp]) + + +async def test_agent_run_raises_on_duplicate_agent_mcp_names(chat_client_base: Any) -> None: + agent = Agent( + client=chat_client_base, + name="TestAgent", + tools=[ + _ConnectedMCPTool(name="docs-mcp", function_names=["search"]), + _ConnectedMCPTool(name="github-mcp", function_names=["search"]), + ], + ) + + with raises(ValueError, match="tool_name_prefix"): + await agent.run("hello") + + +async def test_agent_run_accepts_prefixed_mcp_tools(chat_client_base: Any) -> None: + captured_options: list[dict[str, Any]] = [] + + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any + ) -> ChatResponse: + captured_options.append(dict(options)) + return await original_inner(messages=messages, options=options, **kwargs) + + chat_client_base._inner_get_response = capturing_inner + + local_tool = FunctionTool(func=lambda: "local", name="search", description="Local search tool") + agent = Agent( + client=chat_client_base, + name="TestAgent", + tools=[_ConnectedMCPTool(name="docs-mcp", function_names=["search"], tool_name_prefix="docs")], + ) + + await agent.run("hello", tools=[local_tool]) + + tool_names = [tool.name for tool in captured_options[0]["tools"]] + assert tool_names == ["search", "docs_search"] + + async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None: """Verify tool execution receives 'session' inside **kwargs when function is called by client.""" @@ -1291,7 +1389,7 @@ def test_merge_options_none_values_ignored(): def test_merge_options_tools_combined(): - """Test _merge_options combines tool lists without duplicates.""" + """Test _merge_options raises when distinct tools share the same name.""" class MockTool: def __init__(self, name): @@ -1304,13 +1402,8 @@ def test_merge_options_tools_combined(): base = {"tools": [tool1]} override = {"tools": [tool2, tool3]} - result = _merge_options(base, override) - - # Should have tool1 and tool2, but not duplicate tool3 - assert len(result["tools"]) == 2 - tool_names = [t.name for t in result["tools"]] - assert "tool1" in tool_names - assert "tool2" in tool_names + with raises(ValueError, match="Duplicate tool name 'tool1'"): + _merge_options(base, override) def test_merge_options_dict_tools_combined(): @@ -1335,7 +1428,7 @@ def test_merge_options_dict_tools_combined(): def test_merge_options_dict_tools_deduplicates(): - """Test _merge_options deduplicates dict-defined tools by function name.""" + """Test _merge_options raises on duplicate dict-defined tool names.""" base = { "tools": [ {"type": "function", "function": {"name": "tool_a"}}, @@ -1348,12 +1441,8 @@ def test_merge_options_dict_tools_deduplicates(): ] } - result = _merge_options(base, override) - - assert len(result["tools"]) == 2 - names = [_get_tool_name(t) for t in result["tools"]] - assert names.count("tool_a") == 1 - assert "tool_b" in names + with raises(ValueError, match="Duplicate tool name 'tool_a'"): + _merge_options(base, override) def test_merge_options_mixed_tools_combined(): @@ -1379,7 +1468,7 @@ def test_merge_options_mixed_tools_combined(): def test_merge_options_mixed_tools_deduplicates(): - """Test _merge_options deduplicates when a dict tool and object tool share the same name.""" + """Test _merge_options raises when a dict tool and object tool share the same name.""" class MockTool: def __init__(self, name): @@ -1392,10 +1481,8 @@ def test_merge_options_mixed_tools_deduplicates(): ] } - result = _merge_options(base, override) - - assert len(result["tools"]) == 1 - assert _get_tool_name(result["tools"][0]) == "tool_a" + with raises(ValueError, match="Duplicate tool name 'tool_a'"): + _merge_options(base, override) def test_merge_options_nameless_tools_not_deduplicated(): @@ -1417,6 +1504,20 @@ def test_merge_options_nameless_tools_not_deduplicated(): assert len(result["tools"]) == 2 +def test_merge_options_same_tool_object_kept_once(): + """Test _merge_options silently keeps a repeated reference to the same tool object once.""" + + class MockTool: + def __init__(self, name): + self.name = name + + tool_a = MockTool("tool_a") + + result = _merge_options({"tools": [tool_a]}, {"tools": [tool_a]}) + + assert result["tools"] == [tool_a] + + def test_get_tool_name_dict_no_function_key(): """_get_tool_name returns None for a dict without a 'function' key.""" assert _get_tool_name({"type": "function"}) is None diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 139b860e21..df3187673a 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -53,6 +53,81 @@ def test_normalize_mcp_name(): assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes" +def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None: + assert MCPStdioTool(name="stdio", command="python", tool_name_prefix="stdio").tool_name_prefix == "stdio" + assert ( + MCPStreamableHTTPTool( + name="http", + url="https://example.com/mcp", + tool_name_prefix="http", + ).tool_name_prefix + == "http" + ) + assert ( + MCPWebsocketTool( + name="ws", + url="wss://example.com/mcp", + tool_name_prefix="ws", + ).tool_name_prefix + == "ws" + ) + + +async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration(): + """Prefixed MCP tool names should still honor unprefixed allow/approval configuration.""" + tool = MCPTool( + name="docs", + tool_name_prefix="docs", + allowed_tools=["search_docs"], + approval_mode={"always_require_approval": ["search_docs"]}, + ) + + mock_session = AsyncMock() + tool.session = mock_session + tool.load_tools_flag = True + + page = Mock() + page.tools = [ + types.Tool( + name="search_docs", + description="Search docs", + inputSchema={"type": "object", "properties": {"query": {"type": "string"}}}, + ), + ] + page.nextCursor = None + mock_session.list_tools = AsyncMock(return_value=page) + + await tool.load_tools() + + assert [function.name for function in tool._functions] == ["docs_search_docs"] + assert [function.name for function in tool.functions] == ["docs_search_docs"] + assert tool.functions[0].approval_mode == "always_require" + + +async def test_load_prompts_with_tool_name_prefix() -> None: + """Prefixed MCP prompt names should be exposed with the configured prefix.""" + tool = MCPTool(name="docs", tool_name_prefix="docs") + + mock_session = AsyncMock() + tool.session = mock_session + tool.load_prompts_flag = True + + page = Mock() + page.prompts = [ + types.Prompt( + name="summarize docs", + description="Summarize docs", + arguments=[types.PromptArgument(name="topic", description="Topic", required=True)], + ), + ] + page.nextCursor = None + mock_session.list_prompts = AsyncMock(return_value=page) + + await tool.load_prompts() + + assert [function.name for function in tool._functions] == ["docs_summarize-docs"] + + def test_mcp_prompt_message_to_ai_content(): """Test conversion from MCP prompt message to AI content.""" mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!")) diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py index 701d88de06..b1613ef28f 100644 --- a/python/samples/02-agents/skills/script_approval/script_approval.py +++ b/python/samples/02-agents/skills/script_approval/script_approval.py @@ -90,7 +90,7 @@ async def main() -> None: # maintained automatically — just send the approval response) while result.user_input_requests: for request in result.user_input_requests: - print(f"\nApproval needed:") + print("\nApproval needed:") print(f" Function: {request.function_call.name}") # type: ignore[union-attr] print(f" Arguments: {request.function_call.arguments}") # type: ignore[union-attr] From a4b9539b62e8de663ac137aafce49efc251dbc34 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 13 Mar 2026 09:58:32 +0100 Subject: [PATCH 04/25] [BREAKING] Python: clean up kwargs across agents, chat clients, tools, and sessions (#4581) * Python: clean up kwargs across agents, chat clients, tools, and sessions (#3642) Audit and refactor public **kwargs usage across core agents, chat clients, tools, sessions, and provider packages per the migration strategy codified in CODING_STANDARD.md. Key changes: - Add explicit runtime buckets: function_invocation_kwargs and client_kwargs on RawAgent.run() and chat client get_response() layers. - Refactor FunctionTool to prefer explicit ctx: FunctionInvocationContext injection; legacy **kwargs tools still work via _forward_runtime_kwargs. - Refactor Agent.as_tool() to use direct JSON schema, always-streaming wrapper, approval_mode parameter, and UserInputRequiredException propagation (integrates PR #4568 behavior). - Remove implicit session bleeding into FunctionInvocationContext; tools that need a session must receive it via function_invocation_kwargs. - Lower chat-client layers after FunctionInvocationLayer accept only compatibility **kwargs (client_kwargs flattened, function_invocation_kwargs ignored). - Add layered docstring composition from Raw... implementations via _docstrings.py helper. - Clean up provider constructors to use explicit additional_properties. - Deprecation warnings on legacy direct kwargs paths. - Update samples, tests, and typing across all 23 packages. Resolves #3642 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * clarified docstring * feedback fixes * Add unit tests for _docstrings.py build/apply helpers Tests cover: no docstring source, no extra kwargs, appending to existing Keyword Args section, inserting after Args, inserting in plain docstrings, multiline descriptions, ordering, and apply_layered_docstring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add test for propagate_session TypeError on non-AgentSession values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add tests for multi-content and empty UserInputRequiredException propagation Cover the branching logic in _try_execute_function_calls for: - Multiple user_input_request items in a single exception (extra_user_input_contents path) - Empty contents list (fallback function_result path) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add tests for DurableAIAgent.get_session forwarding service_session_id Verifies get_session correctly forwards service_session_id and session_id to the executor's get_new_session, replacing the removed kwargs test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify ag-ui test stub to read session from client_kwargs only Remove dual-mode detection (client_kwargs vs raw kwargs fallback) from the test mock. Session is now read exclusively from client_kwargs, matching the settled public calling convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated create and get sessions in durable * fixed docstrings * fix test * updated session handling * updated from main * updated tests --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions/0001-agent-run-response.md | 10 +- python/CODING_STANDARD.md | 5 + .../a2a/agent_framework_a2a/_agent.py | 18 +- .../ag-ui/agent_framework_ag_ui/_client.py | 3 - python/packages/ag-ui/tests/ag_ui/conftest.py | 6 +- .../ag_ui/test_agent_wrapper_comprehensive.py | 20 +- .../agent_framework_anthropic/_chat_client.py | 6 +- .../tests/test_aisearch_context_provider.py | 13 +- .../agent_framework_azure_ai/_chat_client.py | 6 +- .../agent_framework_azure_ai/_client.py | 12 +- .../_embedding_client.py | 8 +- .../_history_provider.py | 17 +- .../agent_framework_bedrock/_chat_client.py | 6 +- .../_embedding_client.py | 8 +- .../claude/agent_framework_claude/_agent.py | 16 +- .../agent_framework_copilotstudio/_agent.py | 10 +- .../packages/core/agent_framework/__init__.py | 2 + .../packages/core/agent_framework/_agents.py | 324 ++++++++++++------ .../packages/core/agent_framework/_clients.py | 129 +++++-- .../core/agent_framework/_docstrings.py | 85 +++++ .../core/agent_framework/_middleware.py | 89 ++++- .../core/agent_framework/_sessions.py | 21 +- .../packages/core/agent_framework/_tools.py | 258 +++++++++++--- .../packages/core/agent_framework/_types.py | 6 +- .../agent_framework/azure/_chat_client.py | 6 +- .../core/agent_framework/exceptions.py | 28 ++ .../core/agent_framework/observability.py | 54 ++- .../agent_framework/openai/_chat_client.py | 150 +++++++- .../packages/core/tests/core/test_agents.py | 208 ++++++++--- .../core/test_as_tool_kwargs_propagation.py | 212 +++++++----- .../packages/core/tests/core/test_clients.py | 57 +++ .../core/tests/core/test_docstrings.py | 175 ++++++++++ .../core/tests/core/test_embedding_client.py | 7 + .../core/test_function_invocation_logic.py | 128 +++++++ .../test_kwargs_propagation_to_ai_function.py | 62 ++++ .../packages/core/tests/core/test_sessions.py | 4 +- python/packages/core/tests/core/test_tools.py | 123 +++++++ .../agent_framework_durabletask/_executors.py | 16 +- .../agent_framework_durabletask/_models.py | 54 +-- .../agent_framework_durabletask/_shim.py | 13 +- .../tests/test_agent_session_id.py | 14 +- .../packages/durabletask/tests/test_client.py | 9 - .../tests/test_orchestration_context.py | 11 - .../packages/durabletask/tests/test_shim.py | 29 +- .../_foundry_local_client.py | 7 +- .../agent_framework_github_copilot/_agent.py | 11 +- .../agent_framework_ollama/_chat_client.py | 6 +- .../_embedding_client.py | 8 +- .../_history_provider.py | 19 +- .../agent_as_tool_with_session_propagation.py | 67 ++-- .../tools/function_tool_with_kwargs.py | 32 +- .../function_tool_with_session_injection.py | 34 +- 52 files changed, 2060 insertions(+), 562 deletions(-) create mode 100644 python/packages/core/agent_framework/_docstrings.py create mode 100644 python/packages/core/tests/core/test_docstrings.py diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index 12724aca3a..6ffebe7e4f 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -4,8 +4,8 @@ status: accepted contact: westey-m date: 2025-07-10 {YYYY-MM-DD when the decision was last updated} deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub -consulted: -informed: +consulted: +informed: --- # Agent Run Responses Design @@ -64,7 +64,7 @@ Approaches observed from the compared SDKs: | AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) | | OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) | | Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. | -| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/user-guide/concepts/streaming/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) | +| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/api/python/strands.agent.agent/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) | | LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | | Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse| | A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) | @@ -496,7 +496,7 @@ We need to decide what AIContent types, each agent response type will be mapped |-|-| | AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. | | Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support | -| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/user-guide/concepts/agents/structured-output/) | +| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/api/python/strands.agent.agent/) | | LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response | | Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/input-output/structured-output/agent) at agent construction time | | A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time | @@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped |-|-| | AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string | | Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) | -| AWS (Strands) | Exposes a `stop_reason` property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) class with options that are tied closely to LLM operations. | +| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/docs/api/python/strands.types.event_loop/) property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) class with options that are tied closely to LLM operations. | | LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | | Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) | | A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). | diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index ccb8e058e3..8611592692 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -127,7 +127,12 @@ def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | Cha Avoid `**kwargs` unless absolutely necessary. It should only be used as an escape route, not for well-known flows of data: - **Prefer named parameters**: If there are known extra arguments being passed, use explicit named parameters instead of kwargs +- **Prefer purpose-specific buckets over generic kwargs**: If a flexible payload is still needed, use an explicit named parameter such as `additional_properties`, `function_invocation_kwargs`, or `client_kwargs` rather than a blanket `**kwargs` - **Subclassing support**: kwargs is acceptable in methods that are part of classes designed for subclassing, allowing subclass-defined kwargs to pass through without issues. In this case, clearly document that kwargs exists for subclass extensibility and not for passing arbitrary data +- **Make known flows explicit first**: For abstract hooks, move known data flows into explicit parameters before leaving `**kwargs` behind for subclass extensibility (for example, prefer `state=` explicitly instead of passing it through kwargs) +- **Prefer explicit metadata containers**: For constructors that expose metadata, prefer an explicit `additional_properties` parameter. +- **Keep SDK passthroughs narrow and documented**: A kwargs escape hatch may be acceptable for provider helper APIs that pass through to a large or unstable external SDK surface, but it should be documented as SDK passthrough and revisited regularly +- **Do not keep passthrough kwargs on wrappers that do not use them**: Convenience wrappers and session helpers should not accept generic kwargs merely to forward or ignore them - **Remove when possible**: In other cases, removing kwargs is likely better than keeping it - **Separate kwargs by purpose**: When combining kwargs for multiple purposes, use specific parameters like `client_kwargs: dict[str, Any]` instead of mixing everything in `**kwargs` - **Always document**: If kwargs must be used, always document how it's used, either by referencing external documentation or explaining its purpose diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 54441ff2b7..c954c90fc0 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -6,7 +6,7 @@ import base64 import json import re import uuid -from collections.abc import AsyncIterable, Awaitable, Sequence +from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from typing import Any, Final, Literal, TypeAlias, overload import httpx @@ -226,6 +226,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): *, stream: Literal[False] = ..., session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, continuation_token: A2AContinuationToken | None = None, background: bool = False, **kwargs: Any, @@ -238,17 +240,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): *, stream: Literal[True], session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, continuation_token: A2AContinuationToken | None = None, background: bool = False, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... - def run( + def run( # pyright: ignore[reportIncompatibleMethodOverride] 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, continuation_token: A2AContinuationToken | None = None, background: bool = False, **kwargs: Any, @@ -261,17 +267,23 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): Keyword Args: stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). + function_invocation_kwargs: Present for compatibility with the shared agent interface. + A2AAgent does not use these values directly. + client_kwargs: Present for compatibility with the shared agent interface. + A2AAgent does not use these values directly. + kwargs: Additional compatibility keyword arguments. + A2AAgent does not use these values directly. continuation_token: Optional token to resume a long-running task instead of starting a new one. background: When True, in-progress task updates surface continuation tokens so the caller can poll or resubscribe later. When False (default), the agent internally waits for the task to complete. - kwargs: Additional keyword arguments. Returns: When stream=False: An Awaitable[AgentResponse]. When stream=True: A ResponseStream of AgentResponseUpdate items. """ + del function_invocation_kwargs, client_kwargs, kwargs if continuation_token is not None: a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe( TaskIdParams(id=continuation_token["task_id"]) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index 7188eb739c..d2fb59bbb6 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -220,7 +220,6 @@ class AGUIChatClient( additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, ) -> None: """Initialize the AG-UI chat client. @@ -231,13 +230,11 @@ class AGUIChatClient( additional_properties: Additional properties to store middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. - **kwargs: Additional arguments passed to BaseChatClient """ super().__init__( additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) self._http_service = AGUIHttpService( endpoint=endpoint, diff --git a/python/packages/ag-ui/tests/ag_ui/conftest.py b/python/packages/ag-ui/tests/ag_ui/conftest.py index b73eddb8ad..42a6967371 100644 --- a/python/packages/ag-ui/tests/ag_ui/conftest.py +++ b/python/packages/ag-ui/tests/ag_ui/conftest.py @@ -98,7 +98,11 @@ class StreamingChatClientStub( options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - self.last_session = kwargs.get("session") + client_kwargs = kwargs.get("client_kwargs") + if isinstance(client_kwargs, Mapping): + self.last_session = cast(AgentSession | None, client_kwargs.get("session")) + else: + self.last_session = None self.last_service_session_id = self.last_session.service_session_id if self.last_session else None return cast( Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]], diff --git a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py index e98eb9c9c4..e6f58ef0fd 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py @@ -702,14 +702,9 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub """Test that when use_service_session is True, the AgentSession used to run the agent is set to the service session ID.""" from agent_framework.ag_ui import AgentFrameworkAgent - request_service_session_id: str | None = None - async def stream_fn( messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: - nonlocal request_service_session_id - session = kwargs.get("session") - request_service_session_id = session.service_session_id if session else None yield ChatResponseUpdate( contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345" ) @@ -719,11 +714,22 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"} + # Spy on agent.run to capture the session kwarg at call time (before streaming mutates it) + captured_service_session_id: str | None = None + original_run = agent.run + + def capturing_run(*args: Any, **kwargs: Any) -> Any: + nonlocal captured_service_session_id + session = kwargs.get("session") + captured_service_session_id = session.service_session_id if session else None + return original_run(*args, **kwargs) + + agent.run = capturing_run # type: ignore[assignment, method-assign] + events: list[Any] = [] async for event in wrapper.run(input_data): events.append(event) - request_service_session_id = agent.client.last_service_session_id - assert request_service_session_id == "conv_123456" # type: ignore[attr-defined] (service_session_id should be set) + assert captured_service_session_id == "conv_123456" async def test_function_approval_mode_executes_tool(streaming_chat_client_stub): diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index c60316f913..a1915a69fb 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -228,11 +228,11 @@ class AnthropicClient( model_id: str | None = None, anthropic_client: AsyncAnthropic | None = None, additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize an Anthropic Agent client. @@ -244,11 +244,11 @@ class AnthropicClient( For instance if you need to set a different base_url for testing or private deployments. additional_beta_flags: Additional beta flags to enable on the client. Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25". + additional_properties: Additional properties stored on the client instance. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. - kwargs: Additional keyword arguments passed to the parent class. Examples: .. code-block:: python @@ -319,9 +319,9 @@ class AnthropicClient( # Initialize parent super().__init__( + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) # Initialize instance variables diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 4c065174ea..9972f1301d 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -17,10 +17,15 @@ from agent_framework_azure_ai_search._context_provider import AzureAISearchConte @pytest.fixture(autouse=True) -def clear_azure_search_environment(monkeypatch: pytest.MonkeyPatch) -> None: - for key in tuple(os.environ): - if key.startswith("AZURE_SEARCH_"): - monkeypatch.delenv(key, raising=False) +def clear_azure_search_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep tests isolated from ambient Azure Search environment variables.""" + for key in ( + "AZURE_SEARCH_ENDPOINT", + "AZURE_SEARCH_INDEX_NAME", + "AZURE_SEARCH_KNOWLEDGE_BASE_NAME", + "AZURE_SEARCH_API_KEY", + ): + monkeypatch.delenv(key, raising=False) class MockSearchResults: diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 185159a6c1..d349ef3247 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -444,11 +444,11 @@ class AzureAIAgentClient( model_deployment_name: str | None = None, credential: AzureCredentialTypes | None = None, should_cleanup_agent: bool = True, + additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize an Azure AI Agent client. @@ -471,11 +471,11 @@ class AzureAIAgentClient( should_cleanup_agent: Whether to cleanup (delete) agents created by this client when the client is closed or context is exited. Defaults to True. Only affects agents created by this client instance; existing agents passed via agent_id are never deleted. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middlewares to include. function_invocation_configuration: Optional function invocation configuration. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. - kwargs: Additional keyword arguments passed to the parent class. Examples: .. code-block:: python @@ -548,9 +548,9 @@ class AzureAIAgentClient( # Initialize parent super().__init__( + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) # Initialize instance variables diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index ba5dd8aad7..1fc6c7c1c9 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -119,9 +119,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ credential: AzureCredentialTypes | None = None, use_latest_version: bool | None = None, allow_preview: bool | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a bare Azure AI client. @@ -145,9 +145,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ use_latest_version: Boolean flag that indicates whether to use latest agent version if it exists in the service. allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``. + additional_properties: Additional properties stored on the client instance. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. - kwargs: Additional keyword arguments passed to the parent class. Examples: .. code-block:: python @@ -217,7 +217,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ # Initialize parent super().__init__( - **kwargs, + additional_properties=additional_properties, ) # Initialize instance variables @@ -1243,11 +1243,11 @@ class AzureAIClient( credential: AzureCredentialTypes | None = None, use_latest_version: bool | None = None, allow_preview: bool | None = None, + additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize an Azure AI client with full layer support. @@ -1268,11 +1268,11 @@ class AzureAIClient( use_latest_version: Boolean flag that indicates whether to use latest agent version if it exists in the service. allow_preview: Enables preview opt-in on internally-created ``AIProjectClient`` + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of chat middlewares to include. function_invocation_configuration: Optional function invocation configuration. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. - kwargs: Additional keyword arguments passed to the parent class. Examples: .. code-block:: python @@ -1319,9 +1319,9 @@ class AzureAIClient( credential=credential, use_latest_version=use_latest_version, allow_preview=allow_preview, + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - **kwargs, ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py index a243f77a38..3daa678333 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py @@ -124,9 +124,9 @@ class RawAzureAIInferenceEmbeddingClient( text_client: EmbeddingsClient | None = None, image_client: ImageEmbeddingsClient | None = None, credential: AzureKeyCredential | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw Azure AI Inference embedding client.""" settings = load_settings( @@ -160,7 +160,7 @@ class RawAzureAIInferenceEmbeddingClient( credential=credential, # type: ignore[arg-type] ) self._endpoint = resolved_endpoint - super().__init__(**kwargs) + super().__init__(additional_properties=additional_properties) async def close(self) -> None: """Close the underlying SDK clients and release resources.""" @@ -376,9 +376,9 @@ class AzureAIInferenceEmbeddingClient( image_client: ImageEmbeddingsClient | None = None, credential: AzureKeyCredential | None = None, otel_provider_name: str | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize an Azure AI Inference embedding client.""" super().__init__( @@ -389,8 +389,8 @@ class AzureAIInferenceEmbeddingClient( text_client=text_client, image_client=image_client, credential=credential, + additional_properties=additional_properties, otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - **kwargs, ) diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 35c4243c37..6d205fa378 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -124,7 +124,13 @@ class CosmosHistoryProvider(BaseHistoryProvider): self._database_client = self._cosmos_client.get_database_client(self.database_name) - async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]: + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: """Retrieve stored messages for this session from Azure Cosmos DB.""" await self._ensure_container_proxy() session_key = self._session_partition_key(session_id) @@ -157,7 +163,14 @@ class CosmosHistoryProvider(BaseHistoryProvider): return messages - async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None: + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: """Persist messages for this session to Azure Cosmos DB.""" if not messages: return diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 7a7e3d8eac..c546ef5535 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -236,11 +236,11 @@ class BedrockChatClient( session_token: str | None = None, client: BaseClient | None = None, boto3_session: Boto3Session | None = None, + additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Create a Bedrock chat client and load AWS credentials. @@ -252,11 +252,11 @@ class BedrockChatClient( session_token: Optional AWS session token for temporary credentials. client: Preconfigured Bedrock runtime client; when omitted a boto3 session is created. boto3_session: Custom boto3 session used to build the runtime client if provided. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middlewares to include. function_invocation_configuration: Optional function invocation configuration env_file_path: Optional .env file path used by ``BedrockSettings`` to load defaults. env_file_encoding: Encoding for the optional .env file. - kwargs: Additional arguments forwarded to ``BaseChatClient``. Examples: .. code-block:: python @@ -303,9 +303,9 @@ class BedrockChatClient( ) super().__init__( + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) self.model_id = chat_model_id self.region = region diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py index d07bdee45c..3161ed4c88 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -104,9 +104,9 @@ class RawBedrockEmbeddingClient( session_token: str | None = None, client: BaseClient | None = None, boto3_session: Boto3Session | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw Bedrock embedding client.""" settings = load_settings( @@ -145,7 +145,7 @@ class RawBedrockEmbeddingClient( self.model_id: str = settings["embedding_model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] self.region = resolved_region - super().__init__(**kwargs) + super().__init__(additional_properties=additional_properties) def service_url(self) -> str: """Get the URL of the service.""" @@ -274,9 +274,9 @@ class BedrockEmbeddingClient( client: BaseClient | None = None, boto3_session: Boto3Session | None = None, otel_provider_name: str | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a Bedrock embedding client.""" super().__init__( @@ -287,8 +287,8 @@ class BedrockEmbeddingClient( session_token=session_token, client=client, boto3_session=boto3_session, + additional_properties=additional_properties, otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - **kwargs, ) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 7ebb0c30fd..23703b2c53 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -590,6 +590,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): *, stream: Literal[False] = ..., session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @@ -600,6 +601,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): *, stream: Literal[True], session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... @@ -609,7 +611,8 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): *, stream: bool = False, session: AgentSession | None = None, - **kwargs: Any, + options: OptionsT | None = None, + **kwargs: Any, # type: ignore ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages. @@ -621,16 +624,16 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): returns an awaitable AgentResponse. session: The conversation session. If session has service_session_id set, the agent will resume that session. - kwargs: Additional keyword arguments including 'options' for runtime options - (model, permission_mode can be changed per-request). + options: Runtime options. Model and permission_mode can be changed per request. + kwargs: Additional keyword arguments for compatibility with the shared agent + interface (e.g. compaction_strategy, tokenizer). Not used by ClaudeAgent. Returns: When stream=True: An ResponseStream for streaming updates. When stream=False: An Awaitable[AgentResponse] with the complete response. """ - options = kwargs.pop("options", None) response = ResponseStream( - self._get_stream(messages, session=session, options=options, **kwargs), + self._get_stream(messages, session=session, options=options), finalizer=self._finalize_response, ) @@ -643,8 +646,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): messages: AgentRunInputs | None = None, *, session: AgentSession | None = None, - options: OptionsT | MutableMapping[str, Any] | None = None, - **kwargs: Any, + options: OptionsT | None = None, ) -> AsyncIterable[AgentResponseUpdate]: """Internal streaming implementation.""" session = session or self.create_session() diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index edacb614a5..fc2a35c72b 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -196,7 +196,6 @@ class CopilotStudioAgent(BaseAgent): *, stream: Literal[False] = False, session: AgentSession | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload @@ -206,7 +205,6 @@ class CopilotStudioAgent(BaseAgent): *, stream: Literal[True], session: AgentSession | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( @@ -215,7 +213,6 @@ class CopilotStudioAgent(BaseAgent): *, stream: bool = False, session: AgentSession | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -229,22 +226,20 @@ class CopilotStudioAgent(BaseAgent): Keyword Args: stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). - kwargs: Additional keyword arguments. Returns: When stream=False: An Awaitable[AgentResponse]. When stream=True: A ResponseStream of AgentResponseUpdate items. """ if stream: - return self._run_stream_impl(messages=messages, session=session, **kwargs) - return self._run_impl(messages=messages, session=session, **kwargs) + return self._run_stream_impl(messages=messages, session=session) + return self._run_impl(messages=messages, session=session) async def _run_impl( self, messages: AgentRunInputs | None = None, *, session: AgentSession | None = None, - **kwargs: Any, ) -> AgentResponse: """Non-streaming implementation of run.""" if not session: @@ -269,7 +264,6 @@ class CopilotStudioAgent(BaseAgent): messages: AgentRunInputs | None = None, *, session: AgentSession | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: """Streaming implementation of run.""" diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 95d9b97d64..0f652f23bd 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -215,6 +215,7 @@ from ._workflows._workflow_executor import ( ) from .exceptions import ( MiddlewareException, + UserInputRequiredException, WorkflowCheckpointException, WorkflowConvergenceException, WorkflowException, @@ -349,6 +350,7 @@ __all__ = [ "TypeCompatibilityError", "UpdateT", "UsageDetails", + "UserInputRequiredException", "ValidationTypeEnum", "Workflow", "WorkflowAgent", diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 2e6cca7dba..c2c6e874f1 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -2,10 +2,10 @@ from __future__ import annotations -import inspect import logging import re import sys +import warnings from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from copy import deepcopy @@ -27,12 +27,13 @@ from uuid import uuid4 from mcp import types from mcp.server.lowlevel import Server from mcp.shared.exceptions import McpError -from pydantic import BaseModel, Field, create_model +from pydantic import BaseModel from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage] from ._clients import BaseChatClient, SupportsChatGetResponse +from ._docstrings import apply_layered_docstring from ._mcp import LOG_LEVEL_MAPPING, MCPTool -from ._middleware import AgentMiddlewareLayer, MiddlewareTypes +from ._middleware import AgentMiddlewareLayer, FunctionInvocationContext, MiddlewareTypes from ._serialization import SerializationMixin from ._sessions import ( AgentSession, @@ -53,7 +54,7 @@ from ._types import ( map_chat_to_agent_update, normalize_messages, ) -from .exceptions import AgentInvalidResponseException +from .exceptions import AgentInvalidResponseException, UserInputRequiredException from .observability import AgentTelemetryLayer if sys.version_info >= (3, 13): @@ -169,8 +170,8 @@ class _RunContext(TypedDict): chat_options: MutableMapping[str, Any] compaction_strategy: CompactionStrategy | None tokenizer: TokenizerProtocol | None - filtered_kwargs: Mapping[str, Any] - finalize_kwargs: Mapping[str, Any] + client_kwargs: Mapping[str, Any] + function_invocation_kwargs: Mapping[str, Any] # region Agent Protocol @@ -218,15 +219,15 @@ class SupportsAgentRun(Protocol): return AgentResponse(messages=[], response_id="custom-response") - def create_session(self, **kwargs): + def create_session(self, *, session_id: str | None = None): from agent_framework import AgentSession - return AgentSession(**kwargs) + return AgentSession(session_id=session_id) - def get_session(self, *, service_session_id, **kwargs): + def get_session(self, service_session_id: str, *, session_id: str | None = None): from agent_framework import AgentSession - return AgentSession(service_session_id=service_session_id, **kwargs) + return AgentSession(service_session_id=service_session_id, session_id=session_id) # Verify the instance satisfies the protocol @@ -245,6 +246,8 @@ class SupportsAgentRun(Protocol): *, stream: Literal[False] = ..., session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: """Get a response from the agent (non-streaming).""" @@ -257,6 +260,8 @@ class SupportsAgentRun(Protocol): *, stream: Literal[True], session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a streaming response from the agent.""" @@ -268,6 +273,8 @@ class SupportsAgentRun(Protocol): *, stream: bool = False, session: AgentSession | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a response from the agent. @@ -282,6 +289,8 @@ class SupportsAgentRun(Protocol): Keyword Args: stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). + function_invocation_kwargs: Keyword arguments forwarded to tool invocation. + client_kwargs: Additional client-specific keyword arguments. kwargs: Additional keyword arguments. Returns: @@ -291,11 +300,11 @@ class SupportsAgentRun(Protocol): """ ... - def create_session(self, **kwargs: Any) -> AgentSession: + def create_session(self, *, session_id: str | None = None) -> AgentSession: """Creates a new conversation session.""" ... - def get_session(self, *, service_session_id: str, **kwargs: Any) -> AgentSession: + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: """Gets or creates a session for a service-managed session ID.""" ... @@ -378,6 +387,13 @@ class BaseAgent(SerializationMixin): additional_properties: Additional properties set on the agent. kwargs: Additional keyword arguments (merged into additional_properties). """ + if kwargs: + warnings.warn( + "Passing additional properties as direct keyword arguments to BaseAgent is deprecated; " + "pass them via additional_properties instead.", + DeprecationWarning, + stacklevel=3, + ) if id is None: id = str(uuid4()) self.id = id @@ -392,27 +408,40 @@ class BaseAgent(SerializationMixin): self.additional_properties: dict[str, Any] = cast(dict[str, Any], additional_properties or {}) self.additional_properties.update(kwargs) - def create_session(self, *, session_id: str | None = None, **kwargs: Any) -> AgentSession: + def create_session(self, *, session_id: str | None = None) -> AgentSession: """Create a new lightweight session. + This will be used by an agent to hold the persisted session. + This depends on the service used, in some cases, or with store=True + this will add the ``service_session_id`` based on the response, + which is then fed back to the API on the next call. + + In other cases, if there is a HistoryProvider setup in the agent, + that is used and it can store state in the session. + + If there is no HistoryProvider and store=False or the default of a service is False. + Then a ``InMemoryHistoryProvider`` instance is added to the agent and used with the session automatically. + The ``InMemoryHistoryProvider`` stores the messages as `state` in the session by default. + Keyword Args: session_id: Optional session ID (generated if not provided). - kwargs: Additional keyword arguments. Returns: A new AgentSession instance. """ return AgentSession(session_id=session_id) - def get_session(self, *, service_session_id: str, session_id: str | None = None, **kwargs: Any) -> AgentSession: - """Get or create a session for a service-managed session ID. + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + """Get a session for a service-managed session ID. + + Only use this to create a session continuing that session id from a service. + Otherwise use ``create_session``. Args: service_session_id: The service-managed session ID. Keyword Args: session_id: Optional local session ID (generated if not provided). - kwargs: Additional keyword arguments. Returns: A new AgentSession instance with service_session_id set. @@ -452,9 +481,8 @@ class BaseAgent(SerializationMixin): description: str | None = None, arg_name: str = "task", arg_description: str | None = None, - stream_callback: Callable[[AgentResponseUpdate], None] - | Callable[[AgentResponseUpdate], Awaitable[None]] - | None = None, + approval_mode: Literal["always_require", "never_require"] = "never_require", + stream_callback: Callable[[AgentResponseUpdate], Awaitable[None] | None] | None = None, propagate_session: bool = False, ) -> FunctionTool: """Create a FunctionTool that wraps this agent. @@ -465,21 +493,15 @@ class BaseAgent(SerializationMixin): arg_name: The name of the function argument (default: "task"). arg_description: The description for the function argument. If None, defaults to "Task for {tool_name}". + approval_mode: Whether this delegated tool requires approval before execution. stream_callback: Optional callback for streaming responses. If provided, uses run(..., stream=True). - propagate_session: If True, the parent agent's ``AgentSession`` is - forwarded to this sub-agent's ``run()`` call, so both agents - operate within the same logical session (sharing the same - ``session_id`` and provider-managed state, such as any stored - conversation history or metadata). Defaults to False, meaning - the sub-agent runs with a new, independent session. + propagate_session: If True, the parent agent's session is forwarded + to this sub-agent's ``run()`` call so both agents share the + same session. Defaults to False. Returns: A FunctionTool that can be used as a tool by other agents. - Raises: - TypeError: If the agent does not implement SupportsAgentRun. - ValueError: If the agent tool name cannot be determined. - Examples: .. code-block:: python @@ -507,59 +529,46 @@ class BaseAgent(SerializationMixin): tool_description = description or self.description or "" argument_description = arg_description or f"Task for {tool_name}" - # Create dynamic input model with the specified argument name - field_info = Field(..., description=argument_description) - model_name = f"{name or _sanitize_agent_name(self.name) or 'agent'}_task" - input_model = create_model(model_name, **{arg_name: (str, field_info)}) # type: ignore[call-overload] + input_schema = { + "type": "object", + "properties": { + arg_name: { + "type": "string", + "description": argument_description, + } + }, + "required": [arg_name], + "additionalProperties": False, + } - # Check if callback is async once, outside the wrapper - is_async_callback = stream_callback is not None and inspect.iscoroutinefunction(stream_callback) + async def _agent_wrapper(ctx: FunctionInvocationContext, **kwargs: Any) -> str: + """Wrapper function that calls the agent. - async def agent_wrapper(**kwargs: Any) -> str: - """Wrapper function that calls the agent.""" - # Extract the input from kwargs using the specified arg_name - input_text = kwargs.get(arg_name, "") + Args: + ctx: the function invocation context used + **kwargs: only used to dynamically load the argument that is defined for this tool. + """ + stream = self.run( + str(kwargs.get(arg_name, "")), + stream=True, + session=ctx.session if propagate_session else None, + function_invocation_kwargs=dict(ctx.kwargs), + ) + if stream_callback is not None: + stream.with_transform_hook(stream_callback) + final_response = await stream.get_final_response() + if final_response.user_input_requests: + raise UserInputRequiredException(contents=final_response.user_input_requests) + # TODO(Copilot): update once #4331 merges + return final_response.text - # Extract parent session when propagate_session is enabled - parent_session = kwargs.get("session") if propagate_session else None - - # Forward runtime context kwargs, excluding framework-internal keys. - forwarded_kwargs = { - k: v for k, v in kwargs.items() if k not in (arg_name, "conversation_id", "options", "session") - } - - if stream_callback is None: - # Use non-streaming mode - return ( - await self.run( - input_text, - stream=False, - session=parent_session, - **forwarded_kwargs, - ) - ).text - - # Use streaming mode - accumulate updates and create final response - response_updates: list[AgentResponseUpdate] = [] - async for update in self.run(input_text, stream=True, session=parent_session, **forwarded_kwargs): - response_updates.append(update) - if is_async_callback: - await stream_callback(update) # type: ignore[misc] - else: - stream_callback(update) - - # Create final text from accumulated updates - return AgentResponse.from_updates(response_updates).text - - agent_tool: FunctionTool = FunctionTool( + return FunctionTool( name=tool_name, description=tool_description, - func=agent_wrapper, - input_model=input_model, # type: ignore - approval_mode="never_require", + func=_agent_wrapper, + input_model=input_schema, + approval_mode=approval_mode, ) - agent_tool._forward_runtime_kwargs = True # type: ignore - return agent_tool # region Agent @@ -801,6 +810,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @@ -815,6 +826,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @@ -829,6 +842,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... @@ -842,6 +857,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages and options. @@ -871,14 +888,23 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: Optional per-run tokenizer override passed to ``client.get_response()``. When omitted, the agent-level override is used, falling back to the client default. - kwargs: Additional keyword arguments for the agent. These are only - passed to functions that are called. + function_invocation_kwargs: Keyword arguments forwarded to tool invocation. + client_kwargs: Additional client-specific keyword arguments for the chat client. + kwargs: Deprecated additional keyword arguments for the agent. + They are forwarded to both tool invocation and the chat client for compatibility. Returns: When stream=False: An Awaitable[AgentResponse] containing the agent's response. When stream=True: A ResponseStream of AgentResponseUpdate items with ``get_final_response()`` for the final AgentResponse. """ + if kwargs: + warnings.warn( + "Passing runtime keyword arguments directly to run() is deprecated; pass tool values via " + "function_invocation_kwargs and client-specific values via client_kwargs instead.", + DeprecationWarning, + stacklevel=2, + ) if not stream: async def _run_non_streaming() -> AgentResponse[Any]: @@ -889,7 +915,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - kwargs=kwargs, + legacy_kwargs=kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) response = cast( ChatResponse[Any], @@ -899,7 +927,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=ctx["chat_options"], # type: ignore[reportArgumentType] compaction_strategy=ctx["compaction_strategy"], tokenizer=ctx["tokenizer"], - **ctx["filtered_kwargs"], + function_invocation_kwargs=ctx["function_invocation_kwargs"], + client_kwargs=ctx["client_kwargs"], ), ) @@ -974,7 +1003,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - kwargs=kwargs, + legacy_kwargs=kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) ctx: _RunContext = ctx_holder["ctx"] # type: ignore[assignment] # Safe: we just assigned it return self.client.get_response( # type: ignore[call-overload, no-any-return] @@ -983,7 +1014,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=ctx["chat_options"], # type: ignore[reportArgumentType] compaction_strategy=ctx["compaction_strategy"], tokenizer=ctx["tokenizer"], - **ctx["filtered_kwargs"], + function_invocation_kwargs=ctx["function_invocation_kwargs"], + client_kwargs=ctx["client_kwargs"], ) def _propagate_conversation_id( @@ -1071,9 +1103,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: Mapping[str, Any] | None, compaction_strategy: CompactionStrategy | None, tokenizer: TokenizerProtocol | None, - kwargs: dict[str, Any], + legacy_kwargs: Mapping[str, Any], + function_invocation_kwargs: Mapping[str, Any] | None, + client_kwargs: Mapping[str, Any] | None, ) -> _RunContext: opts = dict(options) if options else {} + existing_additional_args: dict[str, Any] = opts.pop("additional_function_arguments", None) or {} # Get tools from options or named parameter (named param takes precedence) tools_ = tools if tools is not None else opts.pop("tools", None) @@ -1104,6 +1139,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] input_messages=input_messages, options=opts, ) + default_additional_args = chat_options.pop("additional_function_arguments", None) + if isinstance(default_additional_args, Mapping): + existing_additional_args = { + **dict(cast(Mapping[str, Any], default_additional_args)), + **existing_additional_args, + } agent_name = self._get_agent_name() base_tools = normalize_tools(chat_options.pop("tools", None)) @@ -1135,13 +1176,13 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] duplicate_error_message=mcp_duplicate_message, ) - # Merge runtime kwargs into additional_function_arguments so they're available - # in function middleware context and tool invocation. - existing_additional_args: dict[str, Any] = opts.pop("additional_function_arguments", None) or {} - additional_function_arguments = {**kwargs, **existing_additional_args} - # Include session so as_tool() wrappers with propagate_session=True can access it. - if active_session is not None: - additional_function_arguments["session"] = active_session + # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. + # Legacy compatibility still fans out direct run kwargs into tool runtime kwargs. + effective_function_invocation_kwargs = { + **dict(legacy_kwargs), + **(dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {}), + } + additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} # Build options dict from run() options merged with provided options run_opts: dict[str, Any] = { @@ -1150,7 +1191,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] if active_session else opts.pop("conversation_id", None), "allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None), - "additional_function_arguments": additional_function_arguments or None, "frequency_penalty": opts.pop("frequency_penalty", None), "logit_bias": opts.pop("logit_bias", None), "max_tokens": opts.pop("max_tokens", None), @@ -1174,11 +1214,14 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] # Build session_messages from session context: context messages + input messages session_messages: list[Message] = session_context.get_messages(include_input=True) - # Ensure session is forwarded in kwargs for tool invocation - finalize_kwargs = dict(kwargs) - finalize_kwargs["session"] = active_session - # Filter chat_options from kwargs to prevent duplicate keyword argument - filtered_kwargs = {k: v for k, v in finalize_kwargs.items() if k != "chat_options"} + # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. + # Legacy compatibility still fans out direct run kwargs into client kwargs. + effective_client_kwargs = { + **dict(legacy_kwargs), + **(dict(client_kwargs) if client_kwargs is not None else {}), + } + if active_session is not None: + effective_client_kwargs["session"] = active_session return { "session": active_session, @@ -1189,8 +1232,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] "chat_options": co, "compaction_strategy": compaction_strategy or self.compaction_strategy, "tokenizer": tokenizer or self.tokenizer, - "filtered_kwargs": filtered_kwargs, - "finalize_kwargs": finalize_kwargs, + "client_kwargs": effective_client_kwargs, + "function_invocation_kwargs": additional_function_arguments, } async def _finalize_response( @@ -1440,6 +1483,58 @@ class Agent( For a minimal implementation without these features, use :class:`RawAgent`. """ + @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, + **kwargs: Any, + ) -> 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, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the agent.""" + super_run = cast( + "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", + super().run, # type: ignore[misc] + ) + return super_run( # type: ignore[no-any-return] + messages=messages, + stream=stream, + session=session, + middleware=middleware, + options=options, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) + def __init__( self, client: SupportsChatGetResponse[OptionsCoT], @@ -1471,3 +1566,34 @@ class Agent( tokenizer=tokenizer, **kwargs, ) + + +def _apply_agent_docstrings() -> None: + """Align public agent docstrings with the raw implementation.""" + apply_layered_docstring( + AgentMiddlewareLayer.run, + RawAgent.run, + extra_keyword_args={ + "middleware": """ + Optional per-run agent, chat, and function middleware. + Agent middleware wraps the run itself, while chat and function middleware are forwarded to the + underlying chat-client stack for this call. + """, + }, + ) + apply_layered_docstring(AgentTelemetryLayer.run, AgentMiddlewareLayer.run) + apply_layered_docstring( + Agent.run, + RawAgent.run, + extra_keyword_args={ + "middleware": """ + Optional per-run agent, chat, and function middleware. + Agent middleware wraps the run itself, while chat and function middleware are forwarded to the + underlying chat-client stack for this call. + """, + }, + ) + apply_layered_docstring(Agent.__init__, RawAgent.__init__) + + +_apply_agent_docstrings() diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 5f9c1bb08f..4fd563d3e0 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import sys +import warnings from abc import ABC, abstractmethod from collections.abc import ( AsyncIterable, @@ -27,6 +28,7 @@ from typing import ( from pydantic import BaseModel +from ._docstrings import apply_layered_docstring from ._serialization import SerializationMixin from ._tools import ( FunctionInvocationConfiguration, @@ -105,7 +107,7 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): class CustomChatClient: additional_properties: dict = {} - def get_response(self, messages, *, stream=False, **kwargs): + def get_response(self, messages, *, stream=False, client_kwargs=None, **kwargs): if stream: from agent_framework import ChatResponseUpdate, ResponseStream @@ -149,6 +151,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): options: OptionsContraT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -161,6 +165,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): options: OptionsContraT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -172,6 +178,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): options: OptionsContraT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Send input and return the response. @@ -182,7 +190,9 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): options: Chat options as a TypedDict. compaction_strategy: Optional per-call compaction override. tokenizer: Optional per-call tokenizer override. - **kwargs: Additional chat options. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. + client_kwargs: Additional client-specific keyword arguments. + **kwargs: Deprecated additional client-specific keyword arguments. Returns: When stream=False: An awaitable ChatResponse from the client. @@ -283,23 +293,31 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): def __init__( self, *, - additional_properties: dict[str, Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, **kwargs: Any, ) -> None: """Initialize a BaseChatClient instance. Keyword Args: - additional_properties: Additional properties for the client. compaction_strategy: Optional compaction strategy to apply before model calls. tokenizer: Optional tokenizer used by token-aware compaction strategies. - kwargs: Additional keyword arguments (merged into additional_properties). + additional_properties: Additional properties for the client. + kwargs: Additional keyword arguments (merged into additional_properties for now). """ self.additional_properties = additional_properties or {} self.compaction_strategy = compaction_strategy self.tokenizer = tokenizer - super().__init__(**kwargs) + if kwargs: + warnings.warn( + "Passing additional properties as direct keyword arguments to BaseChatClient is deprecated; " + "pass them via additional_properties instead.", + DeprecationWarning, + stacklevel=3, + ) + self.additional_properties.update(kwargs) + super().__init__() def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: """Convert the instance to a dictionary. @@ -486,7 +504,13 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): When omitted, the client-level default is used. tokenizer: Optional per-call tokenizer override. When omitted, the client-level default is used. - **kwargs: Other keyword arguments, can be used to pass function specific parameters. + **kwargs: Additional compatibility keyword arguments. Lower chat-client layers do not + consume ``function_invocation_kwargs`` directly; if present, it is ignored here + because function invocation has already been handled by upper layers. If a + ``client_kwargs`` mapping is present, it is flattened into standard keyword + arguments before forwarding to ``_inner_get_response()`` so client implementations + can leverage those values, while implementations that ignore + extra kwargs remain compatible. Returns: When streaming a response stream of ChatResponseUpdates, otherwise an Awaitable ChatResponse. @@ -495,12 +519,21 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy=compaction_strategy, tokenizer=tokenizer, ) + compatibility_client_kwargs = kwargs.pop("client_kwargs", None) + kwargs.pop("function_invocation_kwargs", None) + merged_client_kwargs = ( + dict(cast(Mapping[str, Any], compatibility_client_kwargs)) + if isinstance(compatibility_client_kwargs, Mapping) + else {} + ) + merged_client_kwargs.update(kwargs) + if not compaction_overrides: return self._inner_get_response( messages=messages, stream=stream, - options=options or {}, - **kwargs, + options=options or {}, # type: ignore[arg-type] + **merged_client_kwargs, ) if stream: @@ -514,7 +547,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): messages=prepared_messages, stream=True, options=options or {}, - **kwargs, + **merged_client_kwargs, ) if isinstance(stream_response, ResponseStream): return stream_response # type: ignore[reportUnknownVariableType] @@ -534,7 +567,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): messages=prepared_messages, stream=False, options=options or {}, - **kwargs, + **merged_client_kwargs, ) return _get_response() @@ -564,7 +597,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): function_invocation_configuration: FunctionInvocationConfiguration | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + additional_properties: Mapping[str, Any] | None = None, ) -> Agent[OptionsCoT]: """Create a Agent with this client. @@ -590,7 +623,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): client-level compaction defaults remain in effect for each call. tokenizer: Optional agent-level tokenizer override. When omitted, client-level tokenizer defaults remain in effect for each call. - kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``. + additional_properties: Additional properties stored on the created agent. Returns: A Agent instance configured with this chat client. @@ -615,21 +648,24 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): """ from ._agents import Agent - return Agent( - client=self, - id=id, - name=name, - description=description, - instructions=instructions, - tools=tools, - default_options=cast(Any, default_options), - context_providers=context_providers, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - **kwargs, - ) + agent_kwargs: dict[str, Any] = { + "client": self, + "id": id, + "name": name, + "description": description, + "instructions": instructions, + "tools": tools, + "default_options": cast(Any, default_options), + "context_providers": context_providers, + "middleware": middleware, + "compaction_strategy": compaction_strategy, + "tokenizer": tokenizer, + "additional_properties": dict(additional_properties) if additional_properties is not None else None, + } + if function_invocation_configuration is not None: + agent_kwargs["function_invocation_configuration"] = function_invocation_configuration + + return Agent(**agent_kwargs) # endregion @@ -892,16 +928,14 @@ class BaseEmbeddingClient(SerializationMixin, ABC, Generic[EmbeddingInputT, Embe self, *, additional_properties: dict[str, Any] | None = None, - **kwargs: Any, ) -> None: """Initialize a BaseEmbeddingClient instance. Args: additional_properties: Additional properties to pass to the client. - **kwargs: Additional keyword arguments passed to parent classes (for MRO). """ self.additional_properties = additional_properties or {} - super().__init__(**kwargs) + super().__init__() @abstractmethod async def get_embeddings( @@ -923,3 +957,36 @@ class BaseEmbeddingClient(SerializationMixin, ABC, Generic[EmbeddingInputT, Embe # endregion + + +def _apply_get_response_docstrings() -> None: + """Align layered chat-client docstrings with the lowest public implementation.""" + from ._middleware import ChatMiddlewareLayer + from ._tools import FunctionInvocationLayer + from .observability import ChatTelemetryLayer + + apply_layered_docstring(ChatTelemetryLayer.get_response, BaseChatClient.get_response) + apply_layered_docstring( + FunctionInvocationLayer.get_response, + ChatTelemetryLayer.get_response, + extra_keyword_args={ + "function_middleware": """ + Optional per-call function middleware. + When omitted, middleware configured on the client or forwarded from higher layers is used. + """, + }, + ) + apply_layered_docstring( + ChatMiddlewareLayer.get_response, + FunctionInvocationLayer.get_response, + extra_keyword_args={ + "middleware": """ + Optional per-call chat and function middleware. + This compatibility keyword argument is merged with any ``client_kwargs["middleware"]`` value + before the request is executed. + """, + }, + ) + + +_apply_get_response_docstrings() diff --git a/python/packages/core/agent_framework/_docstrings.py b/python/packages/core/agent_framework/_docstrings.py new file mode 100644 index 0000000000..44dd7c50a3 --- /dev/null +++ b/python/packages/core/agent_framework/_docstrings.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping +from typing import Any + +_GOOGLE_SECTION_HEADERS = ( + "Args:", + "Keyword Args:", + "Returns:", + "Raises:", + "Examples:", + "Note:", + "Notes:", + "Warning:", + "Warnings:", +) + + +def _find_section_index(lines: list[str], header: str) -> int | None: + for index, line in enumerate(lines): + if line == header: + return index + return None + + +def _find_next_section_index(lines: list[str], start: int) -> int: + for index in range(start, len(lines)): + if lines[index] in _GOOGLE_SECTION_HEADERS: + return index + return len(lines) + + +def _format_keyword_arg_lines(extra_keyword_args: Mapping[str, str]) -> list[str]: + formatted_lines: list[str] = [] + for name, description in extra_keyword_args.items(): + description_lines = inspect.cleandoc(description).splitlines() + if not description_lines: + formatted_lines.append(f" {name}:") + continue + formatted_lines.append(f" {name}: {description_lines[0]}") + formatted_lines.extend(f" {line}" for line in description_lines[1:]) + return formatted_lines + + +def build_layered_docstring( + source: Callable[..., Any], + *, + extra_keyword_args: Mapping[str, str] | None = None, +) -> str | None: + """Build a Google-style docstring from a lower-layer implementation.""" + docstring = inspect.getdoc(source) + if not docstring: + return None + if not extra_keyword_args: + return docstring + + lines = docstring.splitlines() + formatted_keyword_arg_lines = _format_keyword_arg_lines(extra_keyword_args) + keyword_args_index = _find_section_index(lines, "Keyword Args:") + + if keyword_args_index is None: + args_index = _find_section_index(lines, "Args:") + if args_index is not None: + insert_index = _find_next_section_index(lines, args_index + 1) + else: + insert_index = _find_next_section_index(lines, 0) + lines[insert_index:insert_index] = ["", "Keyword Args:", *formatted_keyword_arg_lines] + return "\n".join(lines).rstrip() + + insert_index = _find_next_section_index(lines, keyword_args_index + 1) + lines[insert_index:insert_index] = formatted_keyword_arg_lines + return "\n".join(lines).rstrip() + + +def apply_layered_docstring( + target: Callable[..., Any], + source: Callable[..., Any], + *, + extra_keyword_args: Mapping[str, str] | None = None, +) -> None: + """Copy a lower-layer docstring onto a wrapper and extend it when needed.""" + target.__doc__ = build_layered_docstring(source, extra_keyword_args=extra_keyword_args) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index ba11355adc..66845a2e9d 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -109,7 +109,9 @@ class AgentContext: to see the actual execution result or can be set to override the execution result. For non-streaming: should be AgentResponse. For streaming: should be ResponseStream[AgentResponseUpdate, AgentResponse]. - kwargs: Additional keyword arguments passed to the agent run method. + kwargs: Legacy runtime keyword arguments visible to agent middleware. + client_kwargs: Client-specific keyword arguments for downstream chat clients. + function_invocation_kwargs: Keyword arguments forwarded to tool invocation. Examples: .. code-block:: python @@ -147,6 +149,8 @@ class AgentContext: metadata: Mapping[str, Any] | None = None, result: AgentResponse | ResponseStream[AgentResponseUpdate, AgentResponse] | None = None, kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, stream_transform_hooks: Sequence[ Callable[[AgentResponseUpdate], AgentResponseUpdate | Awaitable[AgentResponseUpdate]] ] @@ -167,7 +171,9 @@ class AgentContext: tokenizer: Optional per-run tokenizer override. metadata: Metadata dictionary for sharing data between agent middleware. result: Agent execution result. - kwargs: Additional keyword arguments passed to the agent run method. + kwargs: Legacy runtime keyword arguments visible to agent middleware. + client_kwargs: Client-specific keyword arguments for downstream chat clients. + function_invocation_kwargs: Keyword arguments forwarded to tool invocation. stream_transform_hooks: Hooks to transform streamed updates. stream_result_hooks: Hooks to process the final result after streaming. stream_cleanup_hooks: Hooks to run after streaming completes. @@ -182,6 +188,10 @@ class AgentContext: self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {} self.result = result self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {} + self.client_kwargs: dict[str, Any] = dict(client_kwargs) if client_kwargs is not None else {} + self.function_invocation_kwargs: dict[str, Any] = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) self.stream_transform_hooks = list(stream_transform_hooks or []) self.stream_result_hooks = list(stream_result_hooks or []) self.stream_cleanup_hooks = list(stream_cleanup_hooks or []) @@ -196,11 +206,11 @@ class FunctionInvocationContext: Attributes: function: The function being invoked. arguments: The validated arguments for the function. + session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. result: Function execution result. Can be observed after calling ``call_next()`` to see the actual execution result or can be set to override the execution result. - - kwargs: Additional keyword arguments passed to the chat method that invoked this function. + kwargs: Additional runtime keyword arguments forwarded to the function invocation. Examples: .. code-block:: python @@ -225,6 +235,7 @@ class FunctionInvocationContext: self, function: FunctionTool, arguments: BaseModel | Mapping[str, Any], + session: AgentSession | None = None, metadata: Mapping[str, Any] | None = None, result: Any = None, kwargs: Mapping[str, Any] | None = None, @@ -234,12 +245,14 @@ class FunctionInvocationContext: Args: function: The function being invoked. arguments: The validated arguments for the function. + session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. result: Function execution result. - kwargs: Additional keyword arguments passed to the chat method that invoked this function. + kwargs: Additional runtime keyword arguments forwarded to the function invocation. """ self.function = function self.arguments = arguments + self.session = session self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {} self.result = result self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {} @@ -262,6 +275,7 @@ class ChatContext: For non-streaming: should be ChatResponse. For streaming: should be ResponseStream[ChatResponseUpdate, ChatResponse]. kwargs: Additional keyword arguments passed to the chat client. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. stream_transform_hooks: Hooks applied to transform each streamed update. stream_result_hooks: Hooks applied to the finalized response (after finalizer). stream_cleanup_hooks: Hooks executed after stream consumption (before finalizer). @@ -298,6 +312,7 @@ class ChatContext: metadata: Mapping[str, Any] | None = None, result: ChatResponse | ResponseStream[ChatResponseUpdate, ChatResponse] | None = None, kwargs: Mapping[str, Any] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, stream_transform_hooks: Sequence[ Callable[[ChatResponseUpdate], ChatResponseUpdate | Awaitable[ChatResponseUpdate]] ] @@ -315,6 +330,7 @@ class ChatContext: metadata: Metadata dictionary for sharing data between chat middleware. result: Chat execution result. kwargs: Additional keyword arguments passed to the chat client. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. stream_transform_hooks: Transform hooks to apply to each streamed update. stream_result_hooks: Result hooks to apply to the finalized streaming response. stream_cleanup_hooks: Cleanup hooks to run after streaming completes. @@ -326,6 +342,9 @@ class ChatContext: self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {} self.result = result self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {} + self.function_invocation_kwargs: dict[str, Any] = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) self.stream_transform_hooks = list(stream_transform_hooks or []) self.stream_result_hooks = list(stream_result_hooks or []) self.stream_cleanup_hooks = list(stream_cleanup_hooks or []) @@ -980,6 +999,7 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @@ -992,6 +1012,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -1004,6 +1026,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -1015,6 +1039,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Execute the chat pipeline if middleware is configured.""" @@ -1025,9 +1051,10 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): if tokenizer is not None: kwargs["tokenizer"] = tokenizer - call_middleware = kwargs.pop("middleware", []) + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + call_middleware = kwargs.pop("middleware", effective_client_kwargs.pop("middleware", [])) middleware = categorize_middleware(call_middleware) - kwargs["function_middleware"] = middleware["function"] + effective_client_kwargs["function_middleware"] = middleware["function"] pipeline = ChatMiddlewarePipeline( *self.chat_middleware, @@ -1038,6 +1065,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): messages=messages, stream=stream, options=options, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=effective_client_kwargs, **kwargs, ) @@ -1046,7 +1075,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): messages=list(messages), options=options, stream=stream, - kwargs=kwargs, + kwargs={**effective_client_kwargs, **kwargs}, + function_invocation_kwargs=function_invocation_kwargs, ) async def _execute() -> ChatResponse | ResponseStream[ChatResponseUpdate, ChatResponse] | None: @@ -1079,11 +1109,17 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): self, context: ChatContext ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: """Internal middleware handler to adapt to pipeline.""" + handler_kwargs = dict(context.kwargs) + compaction_strategy = handler_kwargs.pop("compaction_strategy", None) + tokenizer = handler_kwargs.pop("tokenizer", None) return super().get_response( # type: ignore[misc, no-any-return] messages=context.messages, stream=context.stream, options=context.options or {}, - **context.kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=context.function_invocation_kwargs, + client_kwargs=handler_kwargs, ) @@ -1115,6 +1151,8 @@ class AgentMiddlewareLayer: options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @@ -1129,6 +1167,8 @@ class AgentMiddlewareLayer: options: ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @@ -1143,6 +1183,8 @@ class AgentMiddlewareLayer: options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... @@ -1156,6 +1198,8 @@ class AgentMiddlewareLayer: options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """MiddlewareTypes-enabled unified run method.""" @@ -1175,9 +1219,12 @@ class AgentMiddlewareLayer: + run_middleware_list["function"] + run_middleware_list["chat"] ) - combined_kwargs = dict(kwargs) - combined_kwargs["middleware"] = combined_function_chat_middleware if combined_function_chat_middleware else None - + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + if combined_function_chat_middleware: + effective_client_kwargs["middleware"] = combined_function_chat_middleware + effective_function_invocation_kwargs = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) # Execute with middleware if available if not pipeline.has_middlewares: return super().run( # type: ignore[misc, no-any-return] @@ -1187,7 +1234,9 @@ class AgentMiddlewareLayer: options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **combined_kwargs, + function_invocation_kwargs=effective_function_invocation_kwargs, + client_kwargs=effective_client_kwargs, + **kwargs, ) context = AgentContext( @@ -1198,7 +1247,9 @@ class AgentMiddlewareLayer: stream=stream, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - kwargs=combined_kwargs, + kwargs=kwargs, + client_kwargs=effective_client_kwargs, + function_invocation_kwargs=effective_function_invocation_kwargs, ) async def _execute() -> AgentResponse | ResponseStream[AgentResponseUpdate, AgentResponse] | None: @@ -1230,6 +1281,13 @@ class AgentMiddlewareLayer: def _middleware_handler( self, context: AgentContext ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. + client_kwargs = {**context.client_kwargs, **context.kwargs} + # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. + function_invocation_kwargs = { + **context.function_invocation_kwargs, + **{k: v for k, v in context.kwargs.items() if k != "middleware"}, + } return super().run( # type: ignore[misc, no-any-return] context.messages, stream=context.stream, @@ -1237,7 +1295,8 @@ class AgentMiddlewareLayer: options=context.options, compaction_strategy=context.compaction_strategy, tokenizer=context.tokenizer, - **context.kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index 434a8d1fd4..84656824aa 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -392,12 +392,16 @@ class BaseHistoryProvider(BaseContextProvider): self.store_outputs = store_outputs @abstractmethod - async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]: + async def get_messages( + self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any + ) -> list[Message]: """Retrieve stored messages for this session. Args: session_id: The session ID to retrieve messages for. - **kwargs: Additional arguments (e.g., ``state`` for in-memory providers). + state: Optional session state for providers that persist in session state. + Not used by all providers. + **kwargs: Additional subclass-specific extensibility arguments. Returns: List of stored messages. @@ -405,13 +409,22 @@ class BaseHistoryProvider(BaseContextProvider): ... @abstractmethod - async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None: + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: """Persist messages for this session. Args: session_id: The session ID to store messages for. messages: The messages to persist. - **kwargs: Additional arguments (e.g., ``state`` for in-memory providers). + state: Optional session state for providers that persist in session state. + Not used by all providers. + **kwargs: Additional subclass-specific extensibility arguments. """ ... diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index bfb2c7d2cb..4119afec05 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -7,6 +7,8 @@ import inspect import json import logging import sys +import typing +import warnings from collections.abc import ( AsyncIterable, Awaitable, @@ -37,7 +39,7 @@ from opentelemetry.metrics import Histogram, NoOpHistogram from pydantic import BaseModel, Field, ValidationError, create_model from ._serialization import SerializationMixin -from .exceptions import ToolException +from .exceptions import ToolException, UserInputRequiredException from .observability import ( OPERATION_DURATION_BUCKET_BOUNDARIES, OtelAttr, @@ -61,7 +63,8 @@ if TYPE_CHECKING: from ._clients import SupportsChatGetResponse from ._compaction import CompactionStrategy, TokenizerProtocol from ._mcp import MCPTool - from ._middleware import FunctionMiddlewarePipeline, FunctionMiddlewareTypes + from ._middleware import FunctionInvocationContext, FunctionMiddlewarePipeline, FunctionMiddlewareTypes + from ._sessions import AgentSession from ._types import ( ChatOptions, ChatResponse, @@ -187,6 +190,16 @@ def _default_histogram() -> Histogram: ) +def _annotation_includes_function_invocation_context(annotation: Any) -> bool: + """Check whether an annotation resolves to FunctionInvocationContext.""" + from ._middleware import FunctionInvocationContext + + candidates = get_args(annotation) or (annotation,) + return any( + candidate is FunctionInvocationContext or candidate == "FunctionInvocationContext" for candidate in candidates + ) + + ClassT = TypeVar("ClassT", bound="SerializationMixin") @@ -323,6 +336,12 @@ class FunctionTool(SerializationMixin): # FunctionTool-specific attributes self.func = func self._instance = None # Store the instance for bound methods + self._context_parameter_name: str | None = None + self._input_model_explicitly_provided = input_model is not None + # TODO(Copilot): Delete once legacy ``**kwargs`` runtime injection is removed. + self._forward_runtime_kwargs: bool = False + if self.func: + self._discover_injected_parameters() # Initialize schema cache (will be lazily populated) self._input_schema_cached: dict[str, Any] | None = None @@ -349,13 +368,37 @@ class FunctionTool(SerializationMixin): self._invocation_duration_histogram = _default_histogram() self.type: Literal["function_tool"] = "function_tool" self.result_parser = result_parser - self._forward_runtime_kwargs: bool = False - if self.func: - sig = inspect.signature(self.func) - for param in sig.parameters.values(): - if param.kind == inspect.Parameter.VAR_KEYWORD: - self._forward_runtime_kwargs = True - break + + def _discover_injected_parameters(self) -> None: + """Inspect the wrapped function for runtime injection parameters.""" + func = self.func.func if isinstance(self.func, FunctionTool) else self.func + if func is None: + return + + signature = inspect.signature(func) + try: + type_hints = typing.get_type_hints(func) + except Exception: + type_hints = {name: param.annotation for name, param in signature.parameters.items()} + + for name, param in signature.parameters.items(): + if name in {"self", "cls"}: + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + self._forward_runtime_kwargs = True + continue + + annotation = type_hints.get(name, param.annotation) + if self._is_context_parameter(name, annotation): + if self._context_parameter_name is not None: + raise ValueError(f"Function '{self.name}' defines multiple FunctionInvocationContext parameters.") + self._context_parameter_name = name + + def _is_context_parameter(self, name: str, annotation: Any) -> bool: + """Check whether a callable parameter should receive FunctionInvocationContext injection.""" + if _annotation_includes_function_invocation_context(annotation): + return True + return self._input_model_explicitly_provided and name == "ctx" and annotation is inspect.Parameter.empty def __str__(self) -> str: """Return a string representation of the tool.""" @@ -424,6 +467,7 @@ class FunctionTool(SerializationMixin): ) for pname, param in sig.parameters.items() if pname not in {"self", "cls"} + and pname != self._context_parameter_name and param.kind not in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD} } return create_model(f"{self.name}_input", **fields) @@ -461,6 +505,7 @@ class FunctionTool(SerializationMixin): self, *, arguments: BaseModel | Mapping[str, Any] | None = None, + context: FunctionInvocationContext | None = None, **kwargs: Any, ) -> list[Content]: """Run the AI function with the provided arguments as a Pydantic model. @@ -472,7 +517,8 @@ class FunctionTool(SerializationMixin): Keyword Args: arguments: A mapping or model instance containing the arguments for the function. - kwargs: Keyword arguments to pass to the function, will not be used if ``arguments`` is provided. + context: Explicit function invocation context carrying runtime kwargs. + kwargs: Deprecated keyword arguments to pass to the function. Use ``context`` instead. Returns: A list of Content items representing the tool output. @@ -483,14 +529,37 @@ class FunctionTool(SerializationMixin): if self.declaration_only: raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.") global OBSERVABILITY_SETTINGS + from ._middleware import FunctionInvocationContext from ._types import Content from .observability import OBSERVABILITY_SETTINGS parser = self.result_parser or FunctionTool.parse_result - original_kwargs = dict(kwargs) - tool_call_id = original_kwargs.pop("tool_call_id", None) - if arguments is not None: + parameter_names = set(self.parameters().get("properties", {}).keys()) + direct_argument_kwargs = ( + {key: value for key, value in kwargs.items() if key in parameter_names} if arguments is None else {} + ) + runtime_kwargs = dict(context.kwargs) if context is not None else {} + deprecated_runtime_kwargs = { + key: value for key, value in kwargs.items() if key not in direct_argument_kwargs and key != "tool_call_id" + } + if deprecated_runtime_kwargs: + warnings.warn( + "Passing runtime keyword arguments directly to FunctionTool.invoke() is deprecated; " + "pass them via FunctionInvocationContext instead.", + DeprecationWarning, + stacklevel=2, + ) + runtime_kwargs.update(deprecated_runtime_kwargs) + tool_call_id = kwargs.get("tool_call_id", runtime_kwargs.pop("tool_call_id", None)) + if arguments is None and direct_argument_kwargs: + arguments = direct_argument_kwargs + if arguments is None and context is not None: + arguments = context.arguments + + if arguments is None: + validated_arguments: dict[str, Any] = {} + else: try: if isinstance(arguments, Mapping): parsed_arguments = dict(arguments) @@ -512,19 +581,45 @@ class FunctionTool(SerializationMixin): ) except ValidationError as exc: raise TypeError(f"Invalid arguments for '{self.name}': {exc}") from exc - kwargs = _validate_arguments_against_schema( + + validated_arguments = _validate_arguments_against_schema( arguments=parsed_arguments, schema=self.parameters(), tool_name=self.name, ) - if getattr(self, "_forward_runtime_kwargs", False) and original_kwargs: - kwargs.update(original_kwargs) - else: - kwargs = original_kwargs + + effective_context = context + if effective_context is None and self._context_parameter_name is not None: + effective_context = FunctionInvocationContext( + function=self, + arguments=validated_arguments, + kwargs=runtime_kwargs, + ) + if effective_context is not None: + effective_context.function = self + effective_context.arguments = validated_arguments + effective_context.kwargs = dict(runtime_kwargs) + + call_kwargs = dict(validated_arguments) + observable_kwargs = dict(validated_arguments) + + # Legacy runtime kwargs injection path retained for backwards compatibility with tools + # that still declare ``**kwargs``. New tools should consume runtime data via ``ctx``. + legacy_runtime_kwargs = dict(runtime_kwargs) + if self._forward_runtime_kwargs and legacy_runtime_kwargs: + for key, value in legacy_runtime_kwargs.items(): + if key not in call_kwargs: + call_kwargs[key] = value + if key not in observable_kwargs: + observable_kwargs[key] = value + + if self._context_parameter_name is not None and effective_context is not None: + call_kwargs[self._context_parameter_name] = effective_context + if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined] logger.info(f"Function name: {self.name}") - logger.debug(f"Function arguments: {kwargs}") - res = self.__call__(**kwargs) + logger.debug(f"Function arguments: {observable_kwargs}") + res = self.__call__(**call_kwargs) result = await res if inspect.isawaitable(res) else res try: parsed = parser(result) @@ -545,7 +640,7 @@ class FunctionTool(SerializationMixin): # Filter out framework kwargs that are not JSON serializable. serializable_kwargs = { k: v - for k, v in kwargs.items() + for k, v in observable_kwargs.items() if k not in { "chat_options", @@ -571,7 +666,7 @@ class FunctionTool(SerializationMixin): start_time_stamp = perf_counter() end_time_stamp: float | None = None try: - res = self.__call__(**kwargs) + res = self.__call__(**call_kwargs) result = await res if inspect.isawaitable(res) else res end_time_stamp = perf_counter() except Exception as exception: @@ -1218,9 +1313,10 @@ async def _auto_invoke_function( *, config: FunctionInvocationConfiguration, tool_map: dict[str, FunctionTool], + invocation_session: AgentSession | None = None, sequence_index: int | None = None, request_index: int | None = None, - middleware_pipeline: FunctionMiddlewarePipeline | None = None, # Optional MiddlewarePipeline + middleware_pipeline: FunctionMiddlewarePipeline | None = None, ) -> Content: """Invoke a function call requested by the agent, applying middleware that is defined. @@ -1231,6 +1327,7 @@ async def _auto_invoke_function( Keyword Args: config: The function invocation configuration. tool_map: A mapping of tool names to FunctionTool instances. + invocation_session: The agent session for this invocation, if any. sequence_index: The index of the function call in the sequence. request_index: The index of the request iteration. middleware_pipeline: Optional middleware pipeline to apply during execution. @@ -1282,6 +1379,8 @@ async def _auto_invoke_function( for key, value in (custom_args or {}).items() if key not in {"_function_middleware_pipeline", "middleware", "conversation_id"} } + if invocation_session is not None: + runtime_kwargs["session"] = invocation_session try: if not cast(bool, getattr(tool, "_schema_supplied", False)) and tool.input_model is not None: args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True) @@ -1303,19 +1402,31 @@ async def _auto_invoke_function( additional_properties=function_call_content.additional_properties, ) + from ._middleware import FunctionInvocationContext + if middleware_pipeline is None or not middleware_pipeline.has_middlewares: # No middleware - execute directly try: + direct_context = None + if getattr(tool, "_forward_runtime_kwargs", False) or getattr(tool, "_context_parameter_name", None): + direct_context = FunctionInvocationContext( + function=tool, + arguments=args, + session=invocation_session, + kwargs=runtime_kwargs.copy(), + ) function_result = await tool.invoke( arguments=args, + context=direct_context, tool_call_id=function_call_content.call_id, - **runtime_kwargs if getattr(tool, "_forward_runtime_kwargs", False) else {}, ) return Content.from_function_result( call_id=function_call_content.call_id, # type: ignore[arg-type] result=function_result, additional_properties=function_call_content.additional_properties, ) + except UserInputRequiredException: + raise except Exception as exc: message = "Error: Function failed." if config.get("include_detailed_errors", False): @@ -1327,19 +1438,18 @@ async def _auto_invoke_function( additional_properties=function_call_content.additional_properties, ) # Execute through middleware pipeline if available - from ._middleware import FunctionInvocationContext - middleware_context = FunctionInvocationContext( function=tool, arguments=args, + session=invocation_session, kwargs=runtime_kwargs.copy(), ) async def final_function_handler(context_obj: Any) -> Any: return await tool.invoke( arguments=context_obj.arguments, + context=context_obj, tool_call_id=function_call_content.call_id, - **context_obj.kwargs if getattr(tool, "_forward_runtime_kwargs", False) else {}, ) from ._middleware import MiddlewareTermination @@ -1362,6 +1472,8 @@ async def _auto_invoke_function( additional_properties=function_call_content.additional_properties, ) raise + except UserInputRequiredException: + raise except Exception as exc: message = "Error: Function failed." if config.get("include_detailed_errors", False): @@ -1390,7 +1502,8 @@ async def _try_execute_function_calls( function_calls: Sequence[Content], tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]], config: FunctionInvocationConfiguration, - middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports + invocation_session: AgentSession | None = None, + middleware_pipeline: Any = None, ) -> tuple[Sequence[Content], bool]: """Execute multiple function calls concurrently. @@ -1400,6 +1513,7 @@ async def _try_execute_function_calls( function_calls: A sequence of FunctionCallContent to execute. tools: The tools available for execution. config: Configuration for function invocation. + invocation_session: The agent session for this invocation, if any. middleware_pipeline: Optional middleware pipeline to apply during execution. Returns: @@ -1469,6 +1583,8 @@ async def _try_execute_function_calls( # Run all function calls concurrently, handling MiddlewareTermination from ._middleware import MiddlewareTermination + extra_user_input_contents: list[Content] = [] + async def invoke_with_termination_handling( function_call: Content, seq_idx: int, @@ -1479,6 +1595,7 @@ async def _try_execute_function_calls( function_call_content=function_call, # type: ignore[arg-type] custom_args=custom_args, tool_map=tool_map, + invocation_session=invocation_session, sequence_index=seq_idx, request_index=attempt_idx, middleware_pipeline=middleware_pipeline, @@ -1495,6 +1612,26 @@ async def _try_execute_function_calls( result=exc.result, ) return (result_content, True) + except UserInputRequiredException as exc: + if exc.contents: + propagated: list[Content] = [] + for item in exc.contents: + if isinstance(item, Content): + item.call_id = function_call.call_id # type: ignore[attr-defined] + if not item.id: # type: ignore[attr-defined] + item.id = function_call.call_id # type: ignore[attr-defined] + propagated.append(item) + if propagated: + extra_user_input_contents.extend(propagated[1:]) + return (propagated[0], False) + return ( + Content.from_function_result( + call_id=function_call.call_id, # type: ignore[arg-type] + result="Tool requires user input but no request details were provided.", + exception="UserInputRequiredException", + ), + False, + ) execution_results = await asyncio.gather(*[ invoke_with_termination_handling(function_call, seq_idx) for seq_idx, function_call in enumerate(function_calls) @@ -1502,6 +1639,7 @@ async def _try_execute_function_calls( # Unpack results - each is (Content, terminate_flag) contents: list[Content] = [result[0] for result in execution_results] + contents.extend(extra_user_input_contents) # If any function requested termination, terminate the loop should_terminate = any(result[1] for result in execution_results) return (contents, should_terminate) @@ -1514,6 +1652,7 @@ async def _execute_function_calls( function_calls: list[Content], tool_options: dict[str, Any] | None, config: FunctionInvocationConfiguration, + invocation_session: AgentSession | None = None, middleware_pipeline: Any = None, ) -> tuple[list[Content], bool, bool]: tools = _extract_tools(tool_options) @@ -1524,6 +1663,7 @@ async def _execute_function_calls( attempt_idx=attempt_idx, function_calls=function_calls, tools=tools, # type: ignore + invocation_session=invocation_session, middleware_pipeline=middleware_pipeline, config=config, ) @@ -1733,7 +1873,10 @@ def _handle_function_call_results( ) -> FunctionRequestResult: from ._types import Message - if any(fccr.type in {"function_approval_request", "function_call"} for fccr in function_call_results): + if any( + fccr.type in {"function_approval_request", "function_call"} or fccr.user_input_request + for fccr in function_call_results + ): # Only add items that aren't already in the message (e.g. function_approval_request wrappers). # Declaration-only function_call items are already present from the LLM response. new_items = [fccr for fccr in function_call_results if fccr.type != "function_call"] @@ -1901,6 +2044,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @@ -1913,6 +2058,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -1925,6 +2072,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -1937,6 +2086,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: from ._middleware import FunctionMiddlewarePipeline @@ -1947,28 +2098,45 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) super_get_response = super().get_response # type: ignore[misc] + if kwargs: + warnings.warn( + "Passing client-specific keyword arguments directly to get_response() is deprecated; " + "pass them via client_kwargs instead.", + DeprecationWarning, + stacklevel=2, + ) + + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + effective_function_middleware = function_middleware + if effective_function_middleware is None: + middleware_from_client_kwargs = effective_client_kwargs.pop("function_middleware", None) + if middleware_from_client_kwargs is not None: + effective_function_middleware = cast(Sequence[Any], middleware_from_client_kwargs) # ChatMiddleware adds this kwarg function_middleware_pipeline = FunctionMiddlewarePipeline( - *(self.function_middleware), *(function_middleware or []) + *(self.function_middleware), *(effective_function_middleware or []) ) max_errors = self.function_invocation_configuration.get( "max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST ) - additional_function_arguments: dict[str, Any] = {} + additional_function_arguments = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) if options and (additional_opts := options.get("additional_function_arguments")): # type: ignore[attr-defined] - additional_function_arguments = additional_opts # type: ignore + additional_function_arguments.update(cast(Mapping[str, Any], additional_opts)) + from ._sessions import AgentSession as _AgentSession + + raw_session = effective_client_kwargs.get("session") + invocation_session = raw_session if isinstance(raw_session, _AgentSession) else None execute_function_calls = partial( _execute_function_calls, custom_args=additional_function_arguments, config=self.function_invocation_configuration, + invocation_session=invocation_session, middleware_pipeline=function_middleware_pipeline, ) - filtered_kwargs = {k: v for k, v in kwargs.items() if k != "session"} - if compaction_strategy is not None: - filtered_kwargs["compaction_strategy"] = compaction_strategy - if tokenizer is not None: - filtered_kwargs["tokenizer"] = tokenizer + filtered_kwargs = {k: v for k, v in {**effective_client_kwargs, **kwargs}.items() if k != "session"} # Make options mutable so we can update conversation_id during function invocation loop mutable_options: dict[str, Any] = dict(options) if options else {} @@ -2018,7 +2186,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): messages=prepped_messages, stream=False, options=mutable_options, - **filtered_kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + client_kwargs=filtered_kwargs, ), ) @@ -2087,7 +2257,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): messages=prepped_messages, stream=False, options=mutable_options, - **filtered_kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + client_kwargs=filtered_kwargs, ), ) if fcc_messages: @@ -2137,7 +2309,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): messages=prepped_messages, stream=True, options=mutable_options, - **filtered_kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + client_kwargs=filtered_kwargs, ), ) await inner_stream @@ -2229,7 +2403,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): messages=prepped_messages, stream=True, options=mutable_options, - **filtered_kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + client_kwargs=filtered_kwargs, ), ) await final_inner_stream diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index d43032d572..a4e3a57330 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2698,7 +2698,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): stream: AsyncIterable[UpdateT] | Awaitable[AsyncIterable[UpdateT]], *, finalizer: Callable[[Sequence[UpdateT]], FinalT | Awaitable[FinalT]] | None = None, - transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None]] | None = None, + transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT | None] | None]] | None = None, cleanup_hooks: list[Callable[[], Awaitable[None] | None]] | None = None, result_hooks: list[Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None]] | None = None, ) -> None: @@ -2722,7 +2722,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): self._consumed: bool = False self._finalized: bool = False self._final_result: FinalT | None = None - self._transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None]] = ( + self._transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT | None] | None]] = ( transform_hooks if transform_hooks is not None else [] ) self._result_hooks: list[Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None]] = ( @@ -2995,7 +2995,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): def with_transform_hook( self, - hook: Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None], + hook: Callable[[UpdateT], UpdateT | Awaitable[UpdateT | None] | None], ) -> ResponseStream[UpdateT, FinalT]: """Register a transform hook executed for each update during iteration.""" self._transform_hooks.append(hook) diff --git a/python/packages/core/agent_framework/azure/_chat_client.py b/python/packages/core/agent_framework/azure/_chat_client.py index b57abd6faf..21c38f6b57 100644 --- a/python/packages/core/agent_framework/azure/_chat_client.py +++ b/python/packages/core/agent_framework/azure/_chat_client.py @@ -172,12 +172,12 @@ class AzureOpenAIChatClient( # type: ignore[misc] credential: AzureCredentialTypes | AzureTokenProvider | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, middleware: Sequence[MiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, ) -> None: """Initialize an Azure OpenAI Chat completion client. @@ -205,13 +205,13 @@ class AzureOpenAIChatClient( # type: ignore[misc] default_headers: The default headers mapping of string keys to string values for HTTP requests. async_client: An existing client to use. + additional_properties: Additional properties stored on the client instance. env_file_path: Use the environment settings file as a fallback to using env vars. env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'. instruction_role: The role to use for 'instruction' messages, for example, summarization prompts could use `developer` or `system`. middleware: Optional sequence of middleware to apply to requests. function_invocation_configuration: Optional configuration for function invocation behavior. - kwargs: Other keyword parameters. Examples: .. code-block:: python @@ -283,10 +283,10 @@ class AzureOpenAIChatClient( # type: ignore[misc] credential=credential, default_headers=default_headers, client=async_client, + additional_properties=additional_properties, instruction_role=instruction_role, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) @override diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index f38aa38590..4f56c34b5c 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -180,6 +180,34 @@ class ToolExecutionException(ToolException): pass +class UserInputRequiredException(ToolException): + """Raised when a tool wrapping a sub-agent requires user input to proceed. + + This exception carries the ``user_input_request`` Content items emitted by + the sub-agent (e.g., ``oauth_consent_request``, ``function_approval_request``) + so the tool invocation layer can propagate them to the parent agent's response + instead of swallowing them as a generic tool error. + + Args: + contents: The user-input-request Content items from the sub-agent response. + message: Human-readable description of why user input is needed. + """ + + def __init__( + self, + contents: list[Any], + message: str = "Tool requires user input to proceed.", + ) -> None: + """Create a UserInputRequiredException. + + Args: + contents: The user-input-request Content items from the sub-agent response. + message: Human-readable description of why user input is needed. + """ + super().__init__(message, log_level=None) + self.contents = contents + + # endregion # region Middleware Exceptions diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 2407074efc..bcc4e1365d 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1162,11 +1162,35 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - """Trace chat responses with OpenTelemetry spans and metrics.""" + """Trace chat responses with OpenTelemetry spans and metrics. + + Args: + messages: The message or messages to send to the model. + stream: Whether to stream the response. Defaults to False. + options: Chat options as a TypedDict. + compaction_strategy: Optional compaction strategy to apply before model calls. + tokenizer: Optional tokenizer used by token-aware compaction strategies. + + Keyword Args: + kwargs: Compatibility keyword arguments from higher client layers. This layer does + not consume ``function_invocation_kwargs`` directly; if present, it is ignored + because function invocation has already been processed above. If a ``client_kwargs`` + mapping is present, it is flattened into ordinary keyword arguments for tracing and + forwarding so clients that use those values continue to work while clients that + ignore extra kwargs remain compatible. + """ from ._types import ChatResponse, ChatResponseUpdate, ResponseStream # type: ignore[reportUnusedImport] global OBSERVABILITY_SETTINGS super_get_response = super().get_response # type: ignore[misc] + compatibility_client_kwargs = kwargs.pop("client_kwargs", None) + kwargs.pop("function_invocation_kwargs", None) + merged_client_kwargs = ( + dict(cast(Mapping[str, Any], compatibility_client_kwargs)) + if isinstance(compatibility_client_kwargs, Mapping) + else {} + ) + merged_client_kwargs.update(kwargs) if not OBSERVABILITY_SETTINGS.ENABLED: return super_get_response( # type: ignore[no-any-return] @@ -1175,12 +1199,14 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **kwargs, + **merged_client_kwargs, ) opts: dict[str, Any] = options or {} # type: ignore[assignment] provider_name = str(getattr(self, "otel_provider_name", "unknown")) - model_id = kwargs.get("model_id") or opts.get("model_id") or getattr(self, "model_id", None) or "unknown" + model_id = ( + merged_client_kwargs.get("model_id") or opts.get("model_id") or getattr(self, "model_id", None) or "unknown" + ) service_url_func = getattr(self, "service_url", None) service_url = str(service_url_func() if callable(service_url_func) else "unknown") attributes = _get_span_attributes( @@ -1188,7 +1214,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): provider_name=provider_name, model=model_id, service_url=service_url, - **kwargs, + **merged_client_kwargs, ) if stream: @@ -1200,7 +1226,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=opts, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **kwargs, + **merged_client_kwargs, ), ) @@ -1291,7 +1317,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=opts, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **kwargs, + **merged_client_kwargs, ), ) except Exception as exception: @@ -1420,6 +1446,8 @@ class AgentTelemetryLayer: session: AgentSession | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @@ -1432,6 +1460,8 @@ class AgentTelemetryLayer: session: AgentSession | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... @@ -1443,6 +1473,8 @@ class AgentTelemetryLayer: session: AgentSession | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" @@ -1463,11 +1495,15 @@ class AgentTelemetryLayer: session=session, compaction_strategy=compaction_strategy, tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, **kwargs, ) default_options = getattr(self, "default_options", {}) options = kwargs.get("options") + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + merged_client_kwargs.update(kwargs) merged_options: dict[str, Any] = merge_chat_options(default_options, options or {}) attributes = _get_span_attributes( operation_name=OtelAttr.AGENT_INVOKE_OPERATION, @@ -1477,7 +1513,7 @@ class AgentTelemetryLayer: agent_description=getattr(self, "description", None), thread_id=session.service_session_id if session else None, all_options=merged_options, - **kwargs, + **merged_client_kwargs, ) if stream: @@ -1487,6 +1523,8 @@ class AgentTelemetryLayer: session=session, compaction_strategy=compaction_strategy, tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, **kwargs, ) if isinstance(run_result, ResponseStream): @@ -1578,6 +1616,8 @@ class AgentTelemetryLayer: session=session, compaction_strategy=compaction_strategy, tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, **kwargs, ) except Exception as exception: diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index cd99929249..6df57fe428 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -15,7 +15,7 @@ from collections.abc import ( ) from datetime import datetime, timezone from itertools import chain -from typing import Any, Generic, Literal, cast +from typing import Any, Generic, Literal, cast, overload from openai import AsyncOpenAI, BadRequestError from openai.lib._parsing._completions import type_to_response_format_param @@ -30,7 +30,8 @@ from openai.types.chat.completion_create_params import WebSearchOptions from pydantic import BaseModel from .._clients import BaseChatClient -from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer +from .._docstrings import apply_layered_docstring +from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionMiddlewareTypes from .._settings import load_settings from .._tools import ( FunctionInvocationConfiguration, @@ -72,6 +73,7 @@ else: logger = logging.getLogger("agent_framework.openai") +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) @@ -213,6 +215,57 @@ class RawOpenAIChatClient( # type: ignore[misc] # endregion + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: ChatOptions[ResponseModelBoundT], + **kwargs: Any, + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: OpenAIChatOptionsT | ChatOptions[None] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[True], + options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, + **kwargs: Any, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + @override + def get_response( + self, + messages: Sequence[Message], + *, + stream: bool = False, + options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Get a response from the raw OpenAI chat client.""" + super_get_response = cast( + "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", + super().get_response, # type: ignore[misc] + ) + return super_get_response( # type: ignore[no-any-return] + messages=messages, + stream=stream, + options=options, + **kwargs, + ) + @override def _inner_get_response( self, @@ -727,6 +780,77 @@ class OpenAIChatClient( # type: ignore[misc] ): """OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: ChatOptions[ResponseModelBoundT], + function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: OpenAIChatOptionsT | ChatOptions[None] | None = None, + function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[True], + options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, + function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + @override + def get_response( + self, + messages: Sequence[Message], + *, + stream: bool = False, + options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, + function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Get a response from the OpenAI chat client with all standard layers enabled.""" + super_get_response = cast( + "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", + super().get_response, # type: ignore[misc] + ) + return super_get_response( # type: ignore[no-any-return] + messages=messages, + stream=stream, + options=options, + function_middleware=function_middleware, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + middleware=middleware, + **kwargs, + ) + def __init__( self, *, @@ -830,3 +954,25 @@ class OpenAIChatClient( # type: ignore[misc] middleware=middleware, function_invocation_configuration=function_invocation_configuration, ) + + +def _apply_openai_chat_client_docstrings() -> None: + """Align OpenAI chat-client docstrings with the raw implementation.""" + apply_layered_docstring(RawOpenAIChatClient.get_response, BaseChatClient.get_response) + apply_layered_docstring( + OpenAIChatClient.get_response, + RawOpenAIChatClient.get_response, + extra_keyword_args={ + "function_middleware": """ + Optional per-call function middleware. + When omitted, middleware configured on the client or forwarded from higher layers is used. + """, + "middleware": """ + Optional per-call chat and function middleware. + This is merged with any middleware configured on the client for the current request. + """, + }, + ) + + +_apply_openai_chat_client_docstrings() diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 32c098e51c..8e6faa37c4 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import contextlib +import inspect from collections.abc import AsyncIterable, MutableSequence from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -31,6 +32,7 @@ from agent_framework import ( ) from agent_framework._agents import _get_tool_name, _merge_options, _sanitize_agent_name from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_mcp_name +from agent_framework._middleware import FunctionInvocationContext class _FixedTokenizer: @@ -101,6 +103,30 @@ def test_chat_client_agent_type(client: SupportsChatGetResponse) -> None: assert isinstance(chat_client_agent, SupportsAgentRun) +def test_agent_init_docstring_surfaces_raw_agent_constructor_docs() -> None: + docstring = inspect.getdoc(Agent.__init__) + + assert docstring is not None + assert "client: The chat client to use for the agent." in docstring + assert "middleware: List of middleware to intercept agent and function invocations." in docstring + + +def test_agent_run_docstring_surfaces_raw_agent_runtime_docs() -> None: + docstring = inspect.getdoc(Agent.run) + + assert docstring is not None + assert "Run the agent with the given messages and options." in docstring + assert "function_invocation_kwargs: Keyword arguments forwarded to tool invocation." in docstring + assert "middleware: Optional per-run agent, chat, and function middleware." in docstring + + +def test_agent_run_is_defined_on_agent_class() -> None: + signature = inspect.signature(Agent.run) + + assert Agent.run.__qualname__ == "Agent.run" + assert "middleware" in signature.parameters + + async def test_chat_client_agent_init(client: SupportsChatGetResponse) -> None: agent_id = str(uuid4()) agent = Agent(client=client, id=agent_id, description="Test") @@ -121,6 +147,13 @@ async def test_chat_client_agent_init_with_name( assert agent.description == "Test" +def test_agent_init_warns_for_direct_additional_properties(client: SupportsChatGetResponse) -> None: + with pytest.warns(DeprecationWarning, match="additional_properties"): + agent = Agent(client=client, legacy_key="legacy-value") + + assert agent.additional_properties["legacy_key"] == "legacy-value" + + async def test_chat_client_agent_run(client: SupportsChatGetResponse) -> None: agent = Agent(client=client) @@ -253,33 +286,38 @@ async def test_prepare_session_does_not_mutate_agent_chat_options( assert len(agent.default_options["tools"]) == 1 -async def test_prepare_run_context_keeps_compaction_overrides_out_of_kwargs( +async def test_prepare_run_context_handles_function_kwargs( chat_client_base: SupportsChatGetResponse, ) -> None: - strategy = SlidingWindowStrategy(keep_last_groups=2) - tokenizer = _FixedTokenizer(13) agent = Agent(client=chat_client_base) + session = agent.create_session() ctx = await agent._prepare_run_context( # type: ignore[reportPrivateUsage] - messages=[Message(role="user", text="Hello")], - session=None, + messages="Hello", + session=session, tools=None, - options=None, - compaction_strategy=strategy, - tokenizer=tokenizer, - kwargs={"custom_flag": True}, + options={ + "temperature": 0.4, + "additional_function_arguments": {"from_options": "options-value"}, + }, + compaction_strategy=None, + tokenizer=None, + legacy_kwargs={"legacy_key": "legacy-value"}, + function_invocation_kwargs={"runtime_key": "runtime-value"}, + client_kwargs={"client_key": "client-value"}, ) - assert ctx["compaction_strategy"] is strategy - assert ctx["tokenizer"] is tokenizer - assert ctx["filtered_kwargs"].get("custom_flag") is True - assert "compaction_strategy" not in ctx["filtered_kwargs"] - assert "tokenizer" not in ctx["filtered_kwargs"] + assert ctx["chat_options"]["temperature"] == 0.4 + assert "additional_function_arguments" not in ctx["chat_options"] + assert ctx["function_invocation_kwargs"]["from_options"] == "options-value" + assert ctx["function_invocation_kwargs"]["legacy_key"] == "legacy-value" + assert ctx["function_invocation_kwargs"]["runtime_key"] == "runtime-value" + assert "session" not in ctx["function_invocation_kwargs"] + assert ctx["client_kwargs"]["client_key"] == "client-value" + assert ctx["client_kwargs"]["session"] is session -async def test_chat_client_agent_run_with_session( - chat_client_base: SupportsChatGetResponse, -) -> None: +async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None: mock_response = ChatResponse( messages=[Message(role="assistant", contents=[Content.from_text("test response")])], conversation_id="123", @@ -720,8 +758,9 @@ async def test_chat_agent_as_tool_basic(client: SupportsChatGetResponse) -> None assert tool.name == "TestAgent" assert tool.description == "Test agent for as_tool" + assert tool.approval_mode == "never_require" assert hasattr(tool, "func") - assert hasattr(tool, "input_model") + assert tool.input_model is None async def test_chat_agent_as_tool_custom_parameters( @@ -735,13 +774,15 @@ async def test_chat_agent_as_tool_custom_parameters( description="Custom description", arg_name="query", arg_description="Custom input description", + approval_mode="always_require", ) assert tool.name == "CustomTool" assert tool.description == "Custom description" + assert tool.approval_mode == "always_require" # Check that the input model has the custom field name - schema = tool.input_model.model_json_schema() + schema = tool.parameters() assert "query" in schema["properties"] assert schema["properties"]["query"]["description"] == "Custom input description" @@ -760,7 +801,7 @@ async def test_chat_agent_as_tool_defaults(client: SupportsChatGetResponse) -> N assert tool.description == "" # Should default to empty string # Check default input field - schema = tool.input_model.model_json_schema() + schema = tool.parameters() assert "task" in schema["properties"] assert "Task for TestAgent" in schema["properties"]["task"]["description"] @@ -783,12 +824,12 @@ async def test_chat_agent_as_tool_function_execution( tool = agent.as_tool() # Test function execution - result = await tool.invoke(arguments=tool.input_model(task="Hello")) + result = await tool.invoke(arguments={"task": "Hello"}) # Should return the agent's response text as a list of Content items assert isinstance(result, list) assert len(result) == 1 - assert result[0].text == "test response" # From mock chat client + assert result[0].text == "test streaming response another update" # From mock streaming client async def test_chat_agent_as_tool_with_stream_callback( @@ -806,7 +847,7 @@ async def test_chat_agent_as_tool_with_stream_callback( tool = agent.as_tool(stream_callback=stream_callback) # Execute the tool - result = await tool.invoke(arguments=tool.input_model(task="Hello")) + result = await tool.invoke(arguments={"task": "Hello"}) # Should have collected streaming updates assert len(collected_updates) > 0 @@ -826,9 +867,9 @@ async def test_chat_agent_as_tool_with_custom_arg_name( tool = agent.as_tool(arg_name="prompt", arg_description="Custom prompt input") # Test that the custom argument name works - result = await tool.invoke(arguments=tool.input_model(prompt="Test prompt")) + result = await tool.invoke(arguments={"prompt": "Test prompt"}) assert isinstance(result, list) - assert result[0].text == "test response" + assert result[0].text == "test streaming response another update" async def test_chat_agent_as_tool_with_async_stream_callback( @@ -846,7 +887,7 @@ async def test_chat_agent_as_tool_with_async_stream_callback( tool = agent.as_tool(stream_callback=async_stream_callback) # Execute the tool - result = await tool.invoke(arguments=tool.input_model(task="Hello")) + result = await tool.invoke(arguments={"task": "Hello"}) # Should have collected streaming updates assert len(collected_updates) > 0 @@ -877,17 +918,14 @@ async def test_chat_agent_as_tool_name_sanitization( assert tool.name == expected_tool_name, f"Expected {expected_tool_name}, got {tool.name} for input {agent_name}" -async def test_chat_agent_as_tool_propagate_session_true( - client: SupportsChatGetResponse, -) -> None: - """Test that propagate_session=True forwards the parent's session to the sub-agent.""" +async def test_chat_agent_as_tool_propagate_session_true(client: SupportsChatGetResponse) -> None: + """Test that propagate_session=True forwards the session to the sub-agent.""" agent = Agent(client=client, name="SubAgent", description="Sub agent") tool = agent.as_tool(propagate_session=True) parent_session = AgentSession(session_id="parent-session-123") parent_session.state["shared_key"] = "shared_value" - # Spy on the agent's run method to capture the session argument original_run = agent.run captured_session = None @@ -898,16 +936,20 @@ async def test_chat_agent_as_tool_propagate_session_true( agent.run = capturing_run # type: ignore[assignment, method-assign] - await tool.invoke(arguments=tool.input_model(task="Hello"), session=parent_session) + await tool.invoke( + context=FunctionInvocationContext( + function=tool, + arguments={"task": "Hello"}, + session=parent_session, + ) + ) assert captured_session is parent_session assert captured_session.session_id == "parent-session-123" assert captured_session.state["shared_key"] == "shared_value" -async def test_chat_agent_as_tool_propagate_session_false_by_default( - client: SupportsChatGetResponse, -) -> None: +async def test_chat_agent_as_tool_propagate_session_false_by_default(client: SupportsChatGetResponse) -> None: """Test that propagate_session defaults to False and does not forward the session.""" agent = Agent(client=client, name="SubAgent", description="Sub agent") tool = agent.as_tool() # default: propagate_session=False @@ -924,22 +966,25 @@ async def test_chat_agent_as_tool_propagate_session_false_by_default( agent.run = capturing_run # type: ignore[assignment, method-assign] - await tool.invoke(arguments=tool.input_model(task="Hello"), session=parent_session) + await tool.invoke( + context=FunctionInvocationContext( + function=tool, + arguments={"task": "Hello"}, + session=parent_session, + ) + ) assert captured_session is None -async def test_chat_agent_as_tool_propagate_session_shares_state( - client: SupportsChatGetResponse, -) -> None: - """Test that shared session allows the sub-agent to read and write parent's state.""" +async def test_chat_agent_as_tool_propagate_session_shares_state(client: SupportsChatGetResponse) -> None: + """Test that a propagated session allows the sub-agent to read and write parent state.""" agent = Agent(client=client, name="SubAgent", description="Sub agent") tool = agent.as_tool(propagate_session=True) parent_session = AgentSession(session_id="shared-session") parent_session.state["counter"] = 0 - # The sub-agent receives the same session object, so mutations are shared original_run = agent.run captured_session = None @@ -952,9 +997,14 @@ async def test_chat_agent_as_tool_propagate_session_shares_state( agent.run = capturing_run # type: ignore[assignment, method-assign] - await tool.invoke(arguments=tool.input_model(task="Hello"), session=parent_session) + await tool.invoke( + context=FunctionInvocationContext( + function=tool, + arguments={"task": "Hello"}, + session=parent_session, + ) + ) - # The parent's state should reflect the sub-agent's mutation assert parent_session.state["counter"] == 1 @@ -1131,7 +1181,7 @@ async def test_agent_run_accepts_prefixed_mcp_tools(chat_client_base: Any) -> No async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None: - """Verify tool execution receives 'session' inside **kwargs when function is called by client.""" + """Verify legacy **kwargs tools receive the session when agent.run() is called with one.""" captured: dict[str, Any] = {} @@ -1142,7 +1192,6 @@ async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> N captured["has_state"] = session.state is not None if isinstance(session, AgentSession) else False return f"echo: {text}" - # Make the base client emit a function call for our tool chat_client_base.run_responses = [ ChatResponse( messages=Message( @@ -1162,17 +1211,52 @@ async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> N agent = Agent(client=chat_client_base, tools=[echo_session_info]) session = agent.create_session() - result = await agent.run( - "hello", - session=session, - options={"additional_function_arguments": {"session": session}}, - ) + result = await agent.run("hello", session=session) assert result.text == "done" assert captured.get("has_session") is True assert captured.get("has_state") is True +async def test_agent_tool_receives_explicit_session_via_function_invocation_context_kwargs( + chat_client_base: Any, +) -> None: + """Verify ctx-based tools receive the session via FunctionInvocationContext.session.""" + + captured: dict[str, Any] = {} + + @tool(name="capture_session_context", approval_mode="never_require") + def capture_session_context(text: str, ctx: FunctionInvocationContext) -> str: + captured["session"] = ctx.session + captured["has_state"] = ctx.session.state is not None if isinstance(ctx.session, AgentSession) else False + return f"echo: {text}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", + name="capture_session_context", + arguments='{"text": "hello"}', + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", text="done")), + ] + + agent = Agent(client=chat_client_base, tools=[capture_session_context]) + session = agent.create_session() + + result = await agent.run("hello", session=session) + + assert result.text == "done" + assert captured["session"] is session + assert captured["has_state"] is True + + async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_client_base: Any, tool_tool: Any) -> None: """Verify that tool_choice passed to run() overrides agent-level tool_choice.""" @@ -1859,4 +1943,26 @@ async def test_stores_by_default_with_store_false_in_default_options_injects_inm assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) -# endregion +# region as_tool user_input_request propagation + + +async def test_as_tool_raises_on_user_input_request(client: SupportsChatGetResponse) -> None: + """Test that as_tool raises when the wrapped sub-agent requests user input.""" + from agent_framework.exceptions import UserInputRequiredException + + consent_content = Content.from_oauth_consent_request( + consent_link="https://login.microsoftonline.com/consent", + ) + client.streaming_responses = [ # type: ignore[attr-defined] + [ChatResponseUpdate(contents=[consent_content], role="assistant")], + ] + + agent = Agent(client=client, name="OAuthAgent", description="Agent requiring consent") + agent_tool = agent.as_tool() + + with raises(UserInputRequiredException) as exc_info: + await agent_tool.invoke(arguments={"task": "Do something"}) + + assert len(exc_info.value.contents) == 1 + assert exc_info.value.contents[0].type == "oauth_consent_request" + assert exc_info.value.contents[0].consent_link == "https://login.microsoftonline.com/consent" diff --git a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py index da8e907c40..8aa71a4582 100644 --- a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py +++ b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from typing import Any from agent_framework import Agent, ChatResponse, Content, Message, agent_middleware -from agent_framework._middleware import AgentContext +from agent_framework._middleware import AgentContext, FunctionInvocationContext from .conftest import MockChatClient @@ -14,14 +14,28 @@ from .conftest import MockChatClient class TestAsToolKwargsPropagation: """Test cases for kwargs propagation through as_tool() delegation.""" + @staticmethod + def _build_context( + tool: Any, + *, + task: str, + runtime_kwargs: dict[str, Any] | None = None, + ) -> FunctionInvocationContext: + return FunctionInvocationContext( + function=tool, + arguments={"task": task}, + kwargs=runtime_kwargs, + ) + async def test_as_tool_forwards_runtime_kwargs(self, client: MockChatClient) -> None: - """Test that runtime kwargs are forwarded through as_tool() to sub-agent.""" + """Test that runtime kwargs are forwarded through as_tool() to sub-agent tools.""" captured_kwargs: dict[str, Any] = {} + captured_function_invocation_kwargs: dict[str, Any] = {} @agent_middleware async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: - # Capture kwargs passed to the sub-agent captured_kwargs.update(context.kwargs) + captured_function_invocation_kwargs.update(context.function_invocation_kwargs) await call_next() # Setup mock response @@ -39,29 +53,31 @@ class TestAsToolKwargsPropagation: # Create tool from sub-agent tool = sub_agent.as_tool(name="delegate", arg_name="task") - # Directly invoke the tool with kwargs (simulating what happens during agent execution) + # Directly invoke the tool with explicit runtime context (simulating agent execution). _ = await tool.invoke( - arguments=tool.input_model(task="Test delegation"), - api_token="secret-xyz-123", - user_id="user-456", - session_id="session-789", + context=self._build_context( + tool, + task="Test delegation", + runtime_kwargs={ + "api_token": "secret-xyz-123", + "user_id": "user-456", + "session_id": "session-789", + }, + ), ) - # Verify kwargs were forwarded to sub-agent - assert "api_token" in captured_kwargs, f"Expected 'api_token' in {captured_kwargs}" - assert captured_kwargs["api_token"] == "secret-xyz-123" - assert "user_id" in captured_kwargs - assert captured_kwargs["user_id"] == "user-456" - assert "session_id" in captured_kwargs - assert captured_kwargs["session_id"] == "session-789" + assert captured_kwargs == {} + assert captured_function_invocation_kwargs["api_token"] == "secret-xyz-123" + assert captured_function_invocation_kwargs["user_id"] == "user-456" + assert captured_function_invocation_kwargs["session_id"] == "session-789" - async def test_as_tool_excludes_arg_name_from_forwarded_kwargs(self, client: MockChatClient) -> None: - """Test that the arg_name parameter is not forwarded as a kwarg.""" - captured_kwargs: dict[str, Any] = {} + async def test_as_tool_forwards_context_kwargs_verbatim(self, client: MockChatClient) -> None: + """Test that runtime kwargs are forwarded exactly from FunctionInvocationContext.kwargs.""" + captured_function_invocation_kwargs: dict[str, Any] = {} @agent_middleware async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: - captured_kwargs.update(context.kwargs) + captured_function_invocation_kwargs.update(context.function_invocation_kwargs) await call_next() # Setup mock response @@ -79,25 +95,26 @@ class TestAsToolKwargsPropagation: # Invoke tool with both the arg_name field and additional kwargs await tool.invoke( - arguments=tool.input_model(custom_task="Test task"), - api_token="token-123", - custom_task="should_be_excluded", # This should be filtered out + context=FunctionInvocationContext( + function=tool, + arguments={"custom_task": "Test task"}, + kwargs={ + "api_token": "token-123", + "custom_task": "should_be_excluded", + }, + ) ) - # The arg_name ("custom_task") should NOT be in the forwarded kwargs - assert "custom_task" not in captured_kwargs - # But other kwargs should be present - assert "api_token" in captured_kwargs - assert captured_kwargs["api_token"] == "token-123" + assert captured_function_invocation_kwargs["custom_task"] == "should_be_excluded" + assert captured_function_invocation_kwargs["api_token"] == "token-123" async def test_as_tool_nested_delegation_propagates_kwargs(self, client: MockChatClient) -> None: - """Test that kwargs propagate through multiple levels of delegation (A → B → C).""" - captured_kwargs_list: list[dict[str, Any]] = [] + """Test that runtime kwargs propagate through multiple levels of delegation (A -> B -> C).""" + captured_function_invocation_kwargs_list: list[dict[str, Any]] = [] @agent_middleware async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: - # Capture kwargs at each level - captured_kwargs_list.append(dict(context.kwargs)) + captured_function_invocation_kwargs_list.append(dict(context.function_invocation_kwargs)) await call_next() # Setup mock responses to trigger nested tool invocation: B calls tool C, then completes. @@ -140,24 +157,29 @@ class TestAsToolKwargsPropagation: # Invoke tool B with kwargs - should propagate to both B and C await tool_b.invoke( - arguments=tool_b.input_model(task="Test cascade"), - trace_id="trace-abc-123", - tenant_id="tenant-xyz", - options={"additional_function_arguments": {"trace_id": "trace-abc-123", "tenant_id": "tenant-xyz"}}, + context=self._build_context( + tool_b, + task="Test cascade", + runtime_kwargs={ + "trace_id": "trace-abc-123", + "tenant_id": "tenant-xyz", + }, + ), ) - # Verify kwargs were forwarded to the first agent invocation. - assert len(captured_kwargs_list) >= 1 - assert captured_kwargs_list[0].get("trace_id") == "trace-abc-123" - assert captured_kwargs_list[0].get("tenant_id") == "tenant-xyz" + assert len(captured_function_invocation_kwargs_list) >= 1 + assert captured_function_invocation_kwargs_list[0].get("trace_id") == "trace-abc-123" + assert captured_function_invocation_kwargs_list[0].get("tenant_id") == "tenant-xyz" async def test_as_tool_streaming_mode_forwards_kwargs(self, client: MockChatClient) -> None: - """Test that kwargs are forwarded in streaming mode.""" + """Test that runtime kwargs are forwarded in streaming mode.""" captured_kwargs: dict[str, Any] = {} + captured_function_invocation_kwargs: dict[str, Any] = {} @agent_middleware async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: captured_kwargs.update(context.kwargs) + captured_function_invocation_kwargs.update(context.function_invocation_kwargs) await call_next() # Setup mock streaming responses @@ -182,13 +204,15 @@ class TestAsToolKwargsPropagation: # Invoke tool with kwargs while streaming callback is active await tool.invoke( - arguments=tool.input_model(task="Test streaming"), - api_key="streaming-key-999", + context=self._build_context( + tool, + task="Test streaming", + runtime_kwargs={"api_key": "streaming-key-999"}, + ), ) - # Verify kwargs were forwarded even in streaming mode - assert "api_key" in captured_kwargs - assert captured_kwargs["api_key"] == "streaming-key-999" + assert captured_kwargs == {} + assert captured_function_invocation_kwargs["api_key"] == "streaming-key-999" assert len(captured_updates) == 1 async def test_as_tool_empty_kwargs_still_works(self, client: MockChatClient) -> None: @@ -206,18 +230,20 @@ class TestAsToolKwargsPropagation: tool = sub_agent.as_tool() # Invoke without any extra kwargs - should work without errors - result = await tool.invoke(arguments=tool.input_model(task="Simple task")) + result = await tool.invoke(arguments={"task": "Simple task"}) # Verify tool executed successfully assert result is not None async def test_as_tool_kwargs_with_chat_options(self, client: MockChatClient) -> None: - """Test that kwargs including chat_options are properly forwarded.""" + """Test that runtime kwargs are forwarded only via function_invocation_kwargs.""" captured_kwargs: dict[str, Any] = {} + captured_function_invocation_kwargs: dict[str, Any] = {} @agent_middleware async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: captured_kwargs.update(context.kwargs) + captured_function_invocation_kwargs.update(context.function_invocation_kwargs) await call_next() # Setup mock response @@ -235,24 +261,26 @@ class TestAsToolKwargsPropagation: # Invoke with various kwargs await tool.invoke( - arguments=tool.input_model(task="Test with options"), - temperature=0.8, - max_tokens=500, - custom_param="custom_value", + context=self._build_context( + tool, + task="Test with options", + runtime_kwargs={ + "temperature": 0.8, + "max_tokens": 500, + "custom_param": "custom_value", + }, + ), ) - # Verify all kwargs were forwarded - assert "temperature" in captured_kwargs - assert captured_kwargs["temperature"] == 0.8 - assert "max_tokens" in captured_kwargs - assert captured_kwargs["max_tokens"] == 500 - assert "custom_param" in captured_kwargs - assert captured_kwargs["custom_param"] == "custom_value" + assert captured_kwargs == {} + assert captured_function_invocation_kwargs["temperature"] == 0.8 + assert captured_function_invocation_kwargs["max_tokens"] == 500 + assert captured_function_invocation_kwargs["custom_param"] == "custom_value" async def test_as_tool_kwargs_isolated_per_invocation(self, client: MockChatClient) -> None: - """Test that kwargs are isolated per invocation and don't leak between calls.""" - first_call_kwargs: dict[str, Any] = {} - second_call_kwargs: dict[str, Any] = {} + """Test that runtime kwargs are isolated per invocation and don't leak between calls.""" + first_call_function_invocation_kwargs: dict[str, Any] = {} + second_call_function_invocation_kwargs: dict[str, Any] = {} call_count = 0 @agent_middleware @@ -260,9 +288,9 @@ class TestAsToolKwargsPropagation: nonlocal call_count call_count += 1 if call_count == 1: - first_call_kwargs.update(context.kwargs) + first_call_function_invocation_kwargs.update(context.function_invocation_kwargs) elif call_count == 2: - second_call_kwargs.update(context.kwargs) + second_call_function_invocation_kwargs.update(context.function_invocation_kwargs) await call_next() # Setup mock responses for both calls @@ -281,33 +309,35 @@ class TestAsToolKwargsPropagation: # First call with specific kwargs await tool.invoke( - arguments=tool.input_model(task="First task"), - session_id="session-1", - api_token="token-1", + context=self._build_context( + tool, + task="First task", + runtime_kwargs={"session_id": "session-1", "api_token": "token-1"}, + ), ) # Second call with different kwargs await tool.invoke( - arguments=tool.input_model(task="Second task"), - session_id="session-2", - api_token="token-2", + context=self._build_context( + tool, + task="Second task", + runtime_kwargs={"session_id": "session-2", "api_token": "token-2"}, + ), ) - # Verify first call had its own kwargs - assert first_call_kwargs.get("session_id") == "session-1" - assert first_call_kwargs.get("api_token") == "token-1" + assert first_call_function_invocation_kwargs.get("session_id") == "session-1" + assert first_call_function_invocation_kwargs.get("api_token") == "token-1" - # Verify second call had its own kwargs (not leaked from first) - assert second_call_kwargs.get("session_id") == "session-2" - assert second_call_kwargs.get("api_token") == "token-2" + assert second_call_function_invocation_kwargs.get("session_id") == "session-2" + assert second_call_function_invocation_kwargs.get("api_token") == "token-2" - async def test_as_tool_excludes_conversation_id_from_forwarded_kwargs(self, client: MockChatClient) -> None: - """Test that conversation_id is not forwarded to sub-agent.""" - captured_kwargs: dict[str, Any] = {} + async def test_as_tool_forwards_conversation_id_from_context_kwargs(self, client: MockChatClient) -> None: + """Test that conversation_id is forwarded when explicitly present in runtime context kwargs.""" + captured_function_invocation_kwargs: dict[str, Any] = {} @agent_middleware async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: - captured_kwargs.update(context.kwargs) + captured_function_invocation_kwargs.update(context.function_invocation_kwargs) await call_next() # Setup mock response @@ -325,17 +355,17 @@ class TestAsToolKwargsPropagation: # Invoke tool with conversation_id in kwargs (simulating parent's conversation state) await tool.invoke( - arguments=tool.input_model(task="Test delegation"), - conversation_id="conv-parent-456", - api_token="secret-xyz-123", - user_id="user-456", + context=self._build_context( + tool, + task="Test delegation", + runtime_kwargs={ + "conversation_id": "conv-parent-456", + "api_token": "secret-xyz-123", + "user_id": "user-456", + }, + ), ) - # Verify conversation_id was NOT forwarded to sub-agent - assert "conversation_id" not in captured_kwargs, ( - f"conversation_id should not be forwarded, but got: {captured_kwargs}" - ) - - # Verify other kwargs were still forwarded - assert captured_kwargs.get("api_token") == "secret-xyz-123" - assert captured_kwargs.get("user_id") == "user-456" + assert captured_function_invocation_kwargs.get("conversation_id") == "conv-parent-456" + assert captured_function_invocation_kwargs.get("api_token") == "secret-xyz-123" + assert captured_function_invocation_kwargs.get("user_id") == "user-456" diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index b060b183fb..7e150c47c6 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1,9 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect from typing import Any from unittest.mock import patch +import pytest + from agent_framework import ( GROUP_ANNOTATION_KEY, GROUP_TOKEN_COUNT_KEY, @@ -50,6 +53,60 @@ def test_base_client(chat_client_base: SupportsChatGetResponse): assert isinstance(chat_client_base, SupportsChatGetResponse) +def test_base_client_warns_for_direct_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: + with pytest.warns(DeprecationWarning, match="additional_properties"): + client = type(chat_client_base)(legacy_key="legacy-value") + + assert client.additional_properties["legacy_key"] == "legacy-value" + + +def test_base_client_as_agent_uses_explicit_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: + agent = chat_client_base.as_agent(additional_properties={"team": "core"}) + + assert agent.additional_properties == {"team": "core"} + + +def test_openai_chat_client_get_response_docstring_surfaces_layered_runtime_docs() -> None: + from agent_framework.openai import OpenAIChatClient + + docstring = inspect.getdoc(OpenAIChatClient.get_response) + + assert docstring is not None + assert "Get a response from a chat client." in docstring + assert "function_invocation_kwargs" in docstring + assert "function_middleware: Optional per-call function middleware." in docstring + assert "middleware: Optional per-call chat and function middleware." in docstring + + +def test_openai_chat_client_get_response_is_defined_on_openai_class() -> None: + from agent_framework.openai import OpenAIChatClient + + signature = inspect.signature(OpenAIChatClient.get_response) + + assert OpenAIChatClient.get_response.__qualname__ == "OpenAIChatClient.get_response" + assert "function_middleware" in signature.parameters + assert "middleware" in signature.parameters + + +async def test_base_client_get_response_uses_explicit_client_kwargs(chat_client_base: SupportsChatGetResponse) -> None: + async def fake_inner_get_response(**kwargs): + assert kwargs["trace_id"] == "trace-123" + assert "function_invocation_kwargs" not in kwargs + return ChatResponse(messages=[Message(role="assistant", text="ok")]) + + with patch.object( + chat_client_base, + "_inner_get_response", + side_effect=fake_inner_get_response, + ) as mock_inner_get_response: + await chat_client_base.get_response( + [Message(role="user", text="hello")], + function_invocation_kwargs={"tool_request_id": "tool-123"}, + client_kwargs={"trace_id": "trace-123"}, + ) + mock_inner_get_response.assert_called_once() + + async def test_base_client_get_response(chat_client_base: SupportsChatGetResponse): response = await chat_client_base.get_response([Message(role="user", text="Hello")]) assert response.messages[0].role == "assistant" diff --git a/python/packages/core/tests/core/test_docstrings.py b/python/packages/core/tests/core/test_docstrings.py new file mode 100644 index 0000000000..ab4b116422 --- /dev/null +++ b/python/packages/core/tests/core/test_docstrings.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring + +# -- Helpers: stub functions with various docstring shapes -- + + +def _source_with_full_docstring(x: int) -> int: + """Do something useful. + + Args: + x: The input value. + + Keyword Args: + timeout: Max seconds to wait. + + Returns: + The computed result. + """ + return x + + +def _source_with_args_only(x: int) -> int: + """Do something useful. + + Args: + x: The input value. + + Returns: + The computed result. + """ + return x + + +def _source_no_sections() -> None: + """A plain summary with no Google-style sections.""" + + +def _source_no_docstring() -> None: + pass + + +def _target_stub() -> None: + pass + + +# -- build_layered_docstring tests -- + + +def test_build_returns_none_when_source_has_no_docstring() -> None: + result = build_layered_docstring(_source_no_docstring) + assert result is None + + +def test_build_returns_original_when_no_extra_kwargs() -> None: + result = build_layered_docstring(_source_with_full_docstring) + assert result is not None + assert "Do something useful." in result + assert "Keyword Args:" in result + + +def test_build_returns_original_when_extra_kwargs_empty() -> None: + result = build_layered_docstring(_source_with_full_docstring, extra_keyword_args={}) + assert result is not None + assert result == build_layered_docstring(_source_with_full_docstring) + + +def test_build_appends_to_existing_keyword_args_section() -> None: + result = build_layered_docstring( + _source_with_full_docstring, + extra_keyword_args={"retries": "Number of retries."}, + ) + assert result is not None + assert "timeout: Max seconds to wait." in result + assert "retries: Number of retries." in result + # Both should be under Keyword Args + lines = result.splitlines() + kw_index = next(i for i, line in enumerate(lines) if line == "Keyword Args:") + ret_index = next(i for i, line in enumerate(lines) if line == "Returns:") + retries_index = next(i for i, line in enumerate(lines) if "retries:" in line) + assert kw_index < retries_index < ret_index + + +def test_build_inserts_keyword_args_after_args_section() -> None: + result = build_layered_docstring( + _source_with_args_only, + extra_keyword_args={"verbose": "Enable verbose output."}, + ) + assert result is not None + assert "Keyword Args:" in result + assert "verbose: Enable verbose output." in result + lines = result.splitlines() + args_index = next(i for i, line in enumerate(lines) if line == "Args:") + kw_index = next(i for i, line in enumerate(lines) if line == "Keyword Args:") + ret_index = next(i for i, line in enumerate(lines) if line == "Returns:") + assert args_index < kw_index < ret_index + + +def test_build_inserts_keyword_args_in_docstring_with_no_sections() -> None: + result = build_layered_docstring( + _source_no_sections, + extra_keyword_args={"debug": "Enable debug mode."}, + ) + assert result is not None + assert "A plain summary" in result + assert "Keyword Args:" in result + assert "debug: Enable debug mode." in result + + +def test_build_handles_multiline_descriptions() -> None: + result = build_layered_docstring( + _source_with_args_only, + extra_keyword_args={ + "config": "The configuration object.\nMust be a valid mapping.\nDefaults to empty.", + }, + ) + assert result is not None + lines = result.splitlines() + config_line = next(line for line in lines if "config:" in line) + assert "The configuration object." in config_line + # Continuation lines should be indented + config_idx = lines.index(config_line) + assert "Must be a valid mapping." in lines[config_idx + 1] + assert "Defaults to empty." in lines[config_idx + 2] + + +def test_build_preserves_multiple_extra_kwargs_order() -> None: + result = build_layered_docstring( + _source_with_args_only, + extra_keyword_args={ + "alpha": "First.", + "beta": "Second.", + "gamma": "Third.", + }, + ) + assert result is not None + lines = result.splitlines() + alpha_idx = next(i for i, line in enumerate(lines) if "alpha:" in line) + beta_idx = next(i for i, line in enumerate(lines) if "beta:" in line) + gamma_idx = next(i for i, line in enumerate(lines) if "gamma:" in line) + assert alpha_idx < beta_idx < gamma_idx + + +# -- apply_layered_docstring tests -- + + +def test_apply_sets_docstring_on_target() -> None: + def target() -> None: + pass + + apply_layered_docstring(target, _source_with_full_docstring) + assert target.__doc__ is not None + assert "Do something useful." in target.__doc__ + + +def test_apply_with_extra_kwargs() -> None: + def target() -> None: + pass + + apply_layered_docstring( + target, + _source_with_args_only, + extra_keyword_args={"flag": "A boolean flag."}, + ) + assert target.__doc__ is not None + assert "flag: A boolean flag." in target.__doc__ + assert "Keyword Args:" in target.__doc__ + + +def test_apply_sets_none_when_source_has_no_docstring() -> None: + def target() -> None: + """Original.""" + + apply_layered_docstring(target, _source_no_docstring) + assert target.__doc__ is None diff --git a/python/packages/core/tests/core/test_embedding_client.py b/python/packages/core/tests/core/test_embedding_client.py index 71d2bcfd70..1c49c1d012 100644 --- a/python/packages/core/tests/core/test_embedding_client.py +++ b/python/packages/core/tests/core/test_embedding_client.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Sequence +import pytest + from agent_framework import ( BaseEmbeddingClient, Embedding, @@ -63,6 +65,11 @@ def test_base_additional_properties_custom() -> None: assert client.additional_properties == {"key": "value"} +def test_base_embedding_client_rejects_unknown_kwargs() -> None: + with pytest.raises(TypeError): + MockEmbeddingClient(legacy_key="value") # type: ignore[call-arg] + + # --- SupportsGetEmbeddings protocol tests --- diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 59c932f946..3c61040289 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3651,3 +3651,131 @@ class TestUpdateConversationId: # endregion +async def test_user_input_request_propagates_through_as_tool(chat_client_base: SupportsChatGetResponse): + """Test that user_input_request content from a sub-agent wrapped as a tool propagates to the parent response.""" + from agent_framework.exceptions import UserInputRequiredException + + @tool(name="delegate_agent", approval_mode="never_require") + def delegate_tool(task: str) -> str: + del task + raise UserInputRequiredException( + contents=[ + Content.from_oauth_consent_request( + consent_link="https://login.microsoftonline.com/consent", + ) + ] + ) + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="1", name="delegate_agent", arguments='{"task": "do it"}'), + ], + ) + ) + ] + + response = await chat_client_base.get_response( + [Message(role="user", text="delegate this")], + options={"tool_choice": "auto", "tools": [delegate_tool]}, + ) + + user_requests = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, Content) and content.user_input_request + ] + assert len(user_requests) == 1 + assert user_requests[0].type == "oauth_consent_request" + assert user_requests[0].consent_link == "https://login.microsoftonline.com/consent" + assert user_requests[0].user_input_request is True + + +async def test_user_input_request_multiple_contents_propagate(chat_client_base: SupportsChatGetResponse): + """Test that multiple user_input_request items in a single exception all propagate to the parent response.""" + from agent_framework.exceptions import UserInputRequiredException + + @tool(name="multi_request_tool", approval_mode="never_require") + def multi_request(task: str) -> str: + del task + raise UserInputRequiredException( + contents=[ + Content.from_oauth_consent_request( + consent_link="https://example.com/consent1", + ), + Content.from_oauth_consent_request( + consent_link="https://example.com/consent2", + ), + Content.from_oauth_consent_request( + consent_link="https://example.com/consent3", + ), + ] + ) + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="1", name="multi_request_tool", arguments='{"task": "do it"}'), + ], + ) + ) + ] + + response = await chat_client_base.get_response( + [Message(role="user", text="do something")], + options={"tool_choice": "auto", "tools": [multi_request]}, + ) + + user_requests = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, Content) and content.user_input_request + ] + assert len(user_requests) == 3 + consent_links = {r.consent_link for r in user_requests} + assert consent_links == { + "https://example.com/consent1", + "https://example.com/consent2", + "https://example.com/consent3", + } + + +async def test_user_input_request_empty_contents_returns_fallback(chat_client_base: SupportsChatGetResponse): + """Test that UserInputRequiredException with empty contents produces a fallback function_result.""" + from agent_framework.exceptions import UserInputRequiredException + + @tool(name="empty_request_tool", approval_mode="never_require") + def empty_request(task: str) -> str: + del task + raise UserInputRequiredException(contents=[]) + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="1", name="empty_request_tool", arguments='{"task": "do it"}'), + ], + ) + ), + ChatResponse(messages=Message(role="assistant", text="handled")), + ] + + response = await chat_client_base.get_response( + [Message(role="user", text="do something")], + options={"tool_choice": "auto", "tools": [empty_request]}, + ) + + # With empty contents, the handler returns a function_result with an error message + # and the loop continues to the next chat response. + function_results = [ + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ] + assert len(function_results) >= 1 + assert any("user input" in (fr.result or "").lower() for fr in function_results) diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py index cecd466d86..160ea0fcc4 100644 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py @@ -6,11 +6,13 @@ from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any from agent_framework import ( + Agent, BaseChatClient, ChatMiddlewareLayer, ChatResponse, ChatResponseUpdate, Content, + FunctionInvocationContext, FunctionInvocationLayer, Message, ResponseStream, @@ -97,6 +99,7 @@ class TestKwargsPropagationToFunctionTool: async def test_kwargs_propagate_to_tool_with_kwargs(self) -> None: """Test that kwargs passed to get_response() are available in @tool **kwargs.""" + # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. captured_kwargs: dict[str, Any] = {} @tool(approval_mode="never_require") @@ -149,6 +152,7 @@ class TestKwargsPropagationToFunctionTool: async def test_kwargs_not_forwarded_to_tool_without_kwargs(self) -> None: """Test that kwargs are NOT forwarded to @tool that doesn't accept **kwargs.""" + # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. @tool(approval_mode="never_require") def simple_tool(x: int) -> str: @@ -185,6 +189,7 @@ class TestKwargsPropagationToFunctionTool: async def test_kwargs_isolated_between_function_calls(self) -> None: """Test that kwargs are consistent across multiple function call invocations.""" + # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. invocation_kwargs: list[dict[str, Any]] = [] @tool(approval_mode="never_require") @@ -235,6 +240,7 @@ class TestKwargsPropagationToFunctionTool: async def test_streaming_response_kwargs_propagation(self) -> None: """Test that kwargs propagate to @tool in streaming mode.""" + # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. captured_kwargs: dict[str, Any] = {} @tool(approval_mode="never_require") @@ -287,3 +293,59 @@ class TestKwargsPropagationToFunctionTool: assert "streaming_session" in captured_kwargs, f"Expected 'streaming_session' in {captured_kwargs}" assert captured_kwargs["streaming_session"] == "session-xyz" assert captured_kwargs["correlation_id"] == "corr-123" + + async def test_agent_run_injects_function_invocation_context(self) -> None: + """Test that Agent.run injects FunctionInvocationContext for ctx-based tools.""" + captured_context_kwargs: dict[str, Any] = {} + captured_client_kwargs: dict[str, Any] = {} + captured_options: dict[str, Any] = {} + + @tool(approval_mode="never_require") + def capture_context_tool(x: int, ctx: FunctionInvocationContext) -> str: + captured_context_kwargs.update(ctx.kwargs) + return f"result: x={x}" + + class CapturingFunctionInvokingMockClient(FunctionInvokingMockClient): + async def _get_non_streaming_response( + self, + *, + messages: MutableSequence[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> ChatResponse: + captured_options.update(options) + captured_client_kwargs.update(kwargs) + return await super()._get_non_streaming_response(messages=messages, options=options, **kwargs) + + client = CapturingFunctionInvokingMockClient() + client.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="capture_context_tool", + arguments='{"x": 42}', + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Done!")]), + ] + + agent = Agent(client=client, tools=[capture_context_tool]) + result = await agent.run( + [Message(role="user", text="Test")], + function_invocation_kwargs={"tool_request_id": "tool-123"}, + client_kwargs={"client_request_id": "client-456"}, + ) + + assert captured_context_kwargs["tool_request_id"] == "tool-123" + assert "client_request_id" not in captured_context_kwargs + assert captured_client_kwargs["client_request_id"] == "client-456" + assert "tool_request_id" not in captured_client_kwargs + assert "additional_function_arguments" not in captured_options + assert result.messages[-1].text == "Done!" diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index 4d2e603274..bd2cb8155e 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -192,10 +192,10 @@ class ConcreteHistoryProvider(BaseHistoryProvider): self.stored: list[Message] = [] self._stored_messages = stored_messages or [] - async def get_messages(self, session_id: str | None, **kwargs) -> list[Message]: + async def get_messages(self, session_id: str | None, *, state=None, **kwargs) -> list[Message]: return list(self._stored_messages) - async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs) -> None: + async def save_messages(self, session_id: str | None, messages: Sequence[Message], *, state=None, **kwargs) -> None: self.stored.extend(messages) diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 835f4e3445..859a012e1d 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -12,6 +12,7 @@ from agent_framework import ( FunctionTool, tool, ) +from agent_framework._middleware import FunctionInvocationContext from agent_framework._tools import ( _parse_annotation, _parse_inputs, @@ -952,6 +953,128 @@ async def test_ai_function_with_kwargs_injection(): assert result_default[0].text == "x=10, user=unknown" +async def test_ai_function_with_explicit_invocation_context(): + """Test that invoke() can receive runtime kwargs via FunctionInvocationContext.""" + + @tool + def tool_with_context(x: int, ctx: FunctionInvocationContext) -> str: + """A tool that accepts runtime context injection.""" + user_id = ctx.kwargs.get("user_id", "unknown") + return f"x={x}, user={user_id}" + + assert tool_with_context.parameters() == { + "properties": {"x": {"title": "X", "type": "integer"}}, + "required": ["x"], + "title": "tool_with_context_input", + "type": "object", + } + + context = FunctionInvocationContext( + function=tool_with_context, + arguments=tool_with_context.input_model(x=7), + kwargs={"user_id": "ctx-user"}, + ) + + result = await tool_with_context.invoke(context=context) + + assert result[0].text == "x=7, user=ctx-user" + + +async def test_ai_function_with_typed_context_parameter_using_custom_name(): + """Test that typed context injection works for names other than ctx.""" + + @tool + def tool_with_runtime_context(x: int, runtime: FunctionInvocationContext) -> str: + """A tool that uses a custom context parameter name.""" + user_id = runtime.kwargs.get("user_id", "unknown") + return f"x={x}, user={user_id}" + + assert tool_with_runtime_context.parameters() == { + "properties": {"x": {"title": "X", "type": "integer"}}, + "required": ["x"], + "title": "tool_with_runtime_context_input", + "type": "object", + } + + context = FunctionInvocationContext( + function=tool_with_runtime_context, + arguments=tool_with_runtime_context.input_model(x=8), + kwargs={"user_id": "runtime-user"}, + ) + + result = await tool_with_runtime_context.invoke(context=context) + + assert result[0].text == "x=8, user=runtime-user" + + +async def test_ai_function_with_explicit_schema_and_untyped_ctx(): + """Test that explicit schemas allow an untyped ctx parameter.""" + + class ToolInput(BaseModel): + x: int + + @tool(schema=ToolInput) + def tool_with_schema(x, ctx) -> str: + """A tool with explicit schema and implicit ctx injection.""" + return f"x={x}, user={ctx.kwargs.get('user_id', 'unknown')}" + + context = FunctionInvocationContext( + function=tool_with_schema, + arguments=ToolInput(x=9), + kwargs={"user_id": "schema-user"}, + ) + + result = await tool_with_schema.invoke(context=context) + + assert result[0].text == "x=9, user=schema-user" + + +async def test_ai_function_with_explicit_schema_and_typed_ctx(): + """Test that explicit schemas also work with typed context injection.""" + + class ToolInput(BaseModel): + x: int + + @tool(schema=ToolInput) + def tool_with_schema(x: int, runtime: FunctionInvocationContext) -> str: + """A tool with explicit schema and typed context injection.""" + return f"x={x}, user={runtime.kwargs.get('user_id', 'unknown')}" + + context = FunctionInvocationContext( + function=tool_with_schema, + arguments=ToolInput(x=11), + kwargs={"user_id": "typed-schema-user"}, + ) + + result = await tool_with_schema.invoke(context=context) + + assert tool_with_schema.parameters() == ToolInput.model_json_schema() + assert result[0].text == "x=11, user=typed-schema-user" + + +def test_ai_function_with_multiple_typed_context_parameters_fails(): + """Test that tools reject multiple typed FunctionInvocationContext parameters.""" + + with pytest.raises(ValueError, match="multiple FunctionInvocationContext parameters"): + + @tool + def invalid_tool(ctx_one: FunctionInvocationContext, ctx_two: FunctionInvocationContext) -> str: + return f"{ctx_one.kwargs}-{ctx_two.kwargs}" + + +def test_ai_function_with_ctx_and_typed_context_parameter_fails(): + """Test that explicit-schema tools reject both implicit ctx and typed context parameters.""" + + class ToolInput(BaseModel): + x: int + + with pytest.raises(ValueError, match="multiple FunctionInvocationContext parameters"): + + @tool(schema=ToolInput) + def invalid_tool(x, ctx, runtime: FunctionInvocationContext) -> str: + return f"{x}-{ctx.kwargs}-{runtime.kwargs}" + + # region _parse_annotation tests diff --git a/python/packages/durabletask/agent_framework_durabletask/_executors.py b/python/packages/durabletask/agent_framework_durabletask/_executors.py index 0a7cf50b0b..713c1b4e69 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_executors.py +++ b/python/packages/durabletask/agent_framework_durabletask/_executors.py @@ -124,10 +124,20 @@ class DurableAgentExecutor(ABC, Generic[TaskT]): """ raise NotImplementedError - def get_new_session(self, agent_name: str, **kwargs: Any) -> DurableAgentSession: + def get_new_session( + self, + agent_name: str, + *, + session_id: str | None = None, + service_session_id: str | None = None, + ) -> DurableAgentSession: """Create a new DurableAgentSession with random session ID.""" - session_id = self._create_session_id(agent_name) - return DurableAgentSession.from_session_id(session_id, **kwargs) + durable_session_id = self._create_session_id(agent_name) + return DurableAgentSession( + durable_session_id=durable_session_id, + session_id=session_id, + service_session_id=service_session_id, + ) def _create_session_id( self, diff --git a/python/packages/durabletask/agent_framework_durabletask/_models.py b/python/packages/durabletask/agent_framework_durabletask/_models.py index 1c5484afbf..19d5804bc2 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_models.py +++ b/python/packages/durabletask/agent_framework_durabletask/_models.py @@ -284,46 +284,48 @@ class DurableAgentSession(AgentSession): durable_session_id: AgentSessionId | None = None, session_id: str | None = None, service_session_id: str | None = None, - **kwargs: Any, ) -> None: - super().__init__(session_id=session_id, service_session_id=service_session_id, **kwargs) - self._session_id_value: AgentSessionId | None = durable_session_id + super().__init__(session_id=session_id, service_session_id=service_session_id) + self.durable_session_id: AgentSessionId | None = durable_session_id - @property - def durable_session_id(self) -> AgentSessionId | None: - return self._session_id_value - - @durable_session_id.setter - def durable_session_id(self, value: AgentSessionId | None) -> None: - self._session_id_value = value + def to_dict(self) -> dict[str, Any]: + state = super().to_dict() + if self.durable_session_id is not None: + state[self._SERIALIZED_SESSION_ID_KEY] = str(self.durable_session_id) + return state @classmethod def from_session_id( cls, - session_id: AgentSessionId, - **kwargs: Any, + durable_session_id: AgentSessionId, + *, + session_id: str | None = None, + service_session_id: str | None = None, ) -> DurableAgentSession: - return cls(durable_session_id=session_id, **kwargs) - - def to_dict(self) -> dict[str, Any]: - state = super().to_dict() - if self._session_id_value is not None: - state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id_value) - return state + """Create a DurableAgentSession from an AgentSessionId.""" + return cls( + durable_session_id=durable_session_id, + session_id=session_id, + service_session_id=service_session_id, + ) @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentSession: - state_payload = dict(data) - session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None) - session = super().from_dict(state_payload) + """Create a DurableAgentSession from a state dict.""" + data = dict(data) # defensive copy — avoid mutating caller's dict + session_id_value = data.pop(cls._SERIALIZED_SESSION_ID_KEY, None) + session = super().from_dict(data) + durable_session_id: AgentSessionId | None = None # We need to create a DurableAgentSession from the base AgentSession + if session_id_value is not None: + if not isinstance(session_id_value, str): + raise ValueError("durable_session_id must be a string when present in serialized state") + durable_session_id = AgentSessionId.parse(session_id_value) + durable_session = cls( + durable_session_id=durable_session_id, session_id=session.session_id, service_session_id=session.service_session_id, ) durable_session.state.update(session.state) - if session_id_value is not None: - if not isinstance(session_id_value, str): - raise ValueError("durable_session_id must be a string when present in serialized state") - durable_session._session_id_value = AgentSessionId.parse(session_id_value) return durable_session diff --git a/python/packages/durabletask/agent_framework_durabletask/_shim.py b/python/packages/durabletask/agent_framework_durabletask/_shim.py index 5693876ad7..b21cac6831 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_shim.py +++ b/python/packages/durabletask/agent_framework_durabletask/_shim.py @@ -133,16 +133,13 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): session=session, ) - def create_session(self, **kwargs: Any) -> DurableAgentSession: + def create_session(self, *, session_id: str | None = None) -> DurableAgentSession: """Create a new agent session via the provider.""" - return self._executor.get_new_session(self.name, **kwargs) + return self._executor.get_new_session(self.name) - def get_session(self, **kwargs: Any) -> AgentSession: - """Retrieve an existing session via the provider. - - For durable agents, sessions do not use `service_session_id` so this is not used. - """ - return self._executor.get_new_session(self.name, **kwargs) + def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession: + """Retrieve an existing session via the provider.""" + return self._executor.get_new_session(self.name, service_session_id=service_session_id, session_id=session_id) def _normalize_messages(self, messages: AgentRunInputs | None) -> str: """Convert supported message inputs to a single string. diff --git a/python/packages/durabletask/tests/test_agent_session_id.py b/python/packages/durabletask/tests/test_agent_session_id.py index 571212f145..3902acd22f 100644 --- a/python/packages/durabletask/tests/test_agent_session_id.py +++ b/python/packages/durabletask/tests/test_agent_session_id.py @@ -2,6 +2,8 @@ """Unit tests for AgentSessionId and DurableAgentSession.""" +from typing import Any + import pytest from agent_framework import AgentSession @@ -153,7 +155,7 @@ class TestDurableAgentSession: def test_from_session_id(self) -> None: """Test creating DurableAgentSession from session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key") - session = DurableAgentSession.from_session_id(session_id) + session = DurableAgentSession(durable_session_id=session_id) assert isinstance(session, DurableAgentSession) assert session.durable_session_id is not None @@ -161,10 +163,10 @@ class TestDurableAgentSession: assert session.durable_session_id.name == "TestAgent" assert session.durable_session_id.key == "test-key" - def test_from_session_id_with_service_session_id(self) -> None: - """Test creating DurableAgentSession with service session ID.""" + def test_init_with_service_session_id(self) -> None: + """Test creating DurableAgentSession with explicit service session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key") - session = DurableAgentSession.from_session_id(session_id, service_session_id="service-123") + session = DurableAgentSession(durable_session_id=session_id, service_session_id="service-123") assert session.durable_session_id is not None assert session.durable_session_id == session_id @@ -192,7 +194,7 @@ class TestDurableAgentSession: def test_from_dict_with_durable_session_id(self) -> None: """Test deserialization restores durable session ID.""" - serialized = { + serialized: dict[str, Any] = { "type": "session", "session_id": "session-123", "service_session_id": "service-123", @@ -210,7 +212,7 @@ class TestDurableAgentSession: def test_from_dict_without_durable_session_id(self) -> None: """Test deserialization without durable session ID.""" - serialized = { + serialized: dict[str, Any] = { "type": "session", "session_id": "session-456", "service_session_id": "service-456", diff --git a/python/packages/durabletask/tests/test_client.py b/python/packages/durabletask/tests/test_client.py index 0acdfb2f9c..a056d4e254 100644 --- a/python/packages/durabletask/tests/test_client.py +++ b/python/packages/durabletask/tests/test_client.py @@ -88,15 +88,6 @@ class TestDurableAIAgentClientIntegration: assert isinstance(session, DurableAgentSession) - def test_client_agent_session_with_parameters(self, agent_client: DurableAIAgentClient) -> None: - """Verify agent can create sessions with custom parameters.""" - agent = agent_client.get_agent("assistant") - - session = agent.create_session(service_session_id="client-session-123") - - assert isinstance(session, DurableAgentSession) - assert session.service_session_id == "client-session-123" - class TestDurableAIAgentClientPollingConfiguration: """Test polling configuration parameters for DurableAIAgentClient.""" diff --git a/python/packages/durabletask/tests/test_orchestration_context.py b/python/packages/durabletask/tests/test_orchestration_context.py index 033c274c88..9f7cde156c 100644 --- a/python/packages/durabletask/tests/test_orchestration_context.py +++ b/python/packages/durabletask/tests/test_orchestration_context.py @@ -82,17 +82,6 @@ class TestDurableAIAgentOrchestrationContextIntegration: assert isinstance(session, DurableAgentSession) - def test_orchestration_agent_session_with_parameters( - self, agent_context: DurableAIAgentOrchestrationContext - ) -> None: - """Verify agent can create sessions with custom parameters.""" - agent = agent_context.get_agent("assistant") - - session = agent.create_session(service_session_id="orch-session-456") - - assert isinstance(session, DurableAgentSession) - assert session.service_session_id == "orch-session-456" - if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_shim.py b/python/packages/durabletask/tests/test_shim.py index 423f587871..687a0746a7 100644 --- a/python/packages/durabletask/tests/test_shim.py +++ b/python/packages/durabletask/tests/test_shim.py @@ -184,16 +184,31 @@ class TestDurableAIAgentSessionManagement: mock_executor.get_new_session.assert_called_once_with("test_agent") assert session == mock_session - def test_create_session_forwards_kwargs(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify create_session forwards kwargs to executor.""" - mock_session = DurableAgentSession(service_session_id="session-123") + def test_get_session_forwards_service_session_id( + self, test_agent: DurableAIAgent[Any], mock_executor: Mock + ) -> None: + """Verify get_session forwards service_session_id and session_id to executor.""" + mock_session = DurableAgentSession(service_session_id="svc-123") mock_executor.get_new_session.return_value = mock_session - test_agent.create_session(service_session_id="session-123") + session = test_agent.get_session("svc-123", session_id="local-456") - mock_executor.get_new_session.assert_called_once() - _, kwargs = mock_executor.get_new_session.call_args - assert kwargs["service_session_id"] == "session-123" + mock_executor.get_new_session.assert_called_once_with( + "test_agent", service_session_id="svc-123", session_id="local-456" + ) + assert session.service_session_id == "svc-123" + + def test_get_session_without_session_id(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: + """Verify get_session works with only service_session_id (session_id defaults to None).""" + mock_session = DurableAgentSession(service_session_id="svc-789") + mock_executor.get_new_session.return_value = mock_session + + session = test_agent.get_session("svc-789") + + mock_executor.get_new_session.assert_called_once_with( + "test_agent", service_session_id="svc-789", session_id=None + ) + assert session.service_session_id == "svc-789" class TestDurableAgentProviderInterface: diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 16451ae85a..4c1e64cd7c 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -146,11 +146,11 @@ class FoundryLocalClient( timeout: float | None = None, prepare_model: bool = True, device: DeviceType | None = None, + additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str = "utf-8", - **kwargs: Any, ) -> None: """Initialize a FoundryLocalClient. @@ -169,12 +169,11 @@ class FoundryLocalClient( The device is used to select the appropriate model variant. If not provided, the default device for your system will be used. The values are in the foundry_local.models.DeviceType enum. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. function_invocation_configuration: Optional configuration for function invocation support. env_file_path: If provided, the .env settings are read from this file path location. env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. - kwargs: Additional keyword arguments, are passed to the RawOpenAIChatClient. - This can include middleware and additional properties. Examples: @@ -271,8 +270,8 @@ class FoundryLocalClient( super().__init__( model_id=model_info.id, client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key), + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) self.manager = manager diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 0068f61a49..f8340b1bce 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -303,7 +303,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): stream: Literal[False] = False, session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload @@ -314,7 +313,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): stream: Literal[True], session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( @@ -324,7 +322,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): stream: bool = False, session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -339,7 +336,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). options: Runtime options (model, timeout, etc.). - kwargs: Additional keyword arguments. Returns: When stream=False: An Awaitable[AgentResponse]. @@ -354,10 +350,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): return AgentResponse.from_updates(updates) return ResponseStream( - self._stream_updates(messages=messages, session=session, options=options, **kwargs), + self._stream_updates(messages=messages, session=session, options=options), finalizer=_finalize, ) - return self._run_impl(messages=messages, session=session, options=options, **kwargs) + return self._run_impl(messages=messages, session=session, options=options) async def _run_impl( self, @@ -365,7 +361,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, ) -> AgentResponse: """Non-streaming implementation of run.""" if not self._started: @@ -414,7 +409,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, session: AgentSession | None = None, options: OptionsT | None = None, - **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal method to stream updates from GitHub Copilot. @@ -424,7 +418,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Keyword Args: session: The conversation session associated with the message(s). options: Runtime options (model, timeout, etc.). - kwargs: Additional keyword arguments. Yields: AgentResponseUpdate items. diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 94c46b65e5..b931c89499 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -300,11 +300,11 @@ class OllamaChatClient( host: str | None = None, client: AsyncClient | None = None, model_id: str | None = None, + additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize an Ollama Chat client. @@ -313,11 +313,11 @@ class OllamaChatClient( Can be set via the OLLAMA_HOST env variable. client: An optional Ollama Client instance. If not provided, a new instance will be created. model_id: The Ollama chat model ID to use. Can be set via the OLLAMA_MODEL_ID env variable. + additional_properties: Additional properties stored on the client instance. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. env_file_path: An optional path to a dotenv (.env) file to load environment variables from. env_file_encoding: The encoding to use when reading the dotenv (.env) file. Defaults to 'utf-8'. - **kwargs: Additional keyword arguments passed to BaseChatClient. """ ollama_settings = load_settings( OllamaSettings, @@ -336,9 +336,9 @@ class OllamaChatClient( self.host = str(self.client._client.base_url) # type: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] super().__init__( + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) self.middleware = list(self.chat_middleware) diff --git a/python/packages/ollama/agent_framework_ollama/_embedding_client.py b/python/packages/ollama/agent_framework_ollama/_embedding_client.py index 5cd35fc9f3..8e0508c708 100644 --- a/python/packages/ollama/agent_framework_ollama/_embedding_client.py +++ b/python/packages/ollama/agent_framework_ollama/_embedding_client.py @@ -92,9 +92,9 @@ class RawOllamaEmbeddingClient( model_id: str | None = None, host: str | None = None, client: AsyncClient | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw Ollama embedding client.""" ollama_settings = load_settings( @@ -110,7 +110,7 @@ class RawOllamaEmbeddingClient( self.model_id = ollama_settings["embedding_model_id"] # type: ignore[assignment,reportTypedDictNotRequiredAccess] self.client = client or AsyncClient(host=ollama_settings.get("host")) self.host = str(self.client._client.base_url) # type: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] - super().__init__(**kwargs) + super().__init__(additional_properties=additional_properties) def service_url(self) -> str: """Get the URL of the service.""" @@ -214,17 +214,17 @@ class OllamaEmbeddingClient( host: str | None = None, client: AsyncClient | None = None, otel_provider_name: str | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize an Ollama embedding client.""" super().__init__( model_id=model_id, host=host, client=client, + additional_properties=additional_properties, otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - **kwargs, ) diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index e1a20b6218..be2db098b8 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -107,11 +107,18 @@ class RedisHistoryProvider(BaseHistoryProvider): """Get the Redis key for a given session's messages.""" return f"{self.key_prefix}:{session_id or 'default'}" - async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]: + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: """Retrieve stored messages for this session from Redis. Args: session_id: The session ID to retrieve messages for. + state: Optional session state. Unused for Redis-backed history. **kwargs: Additional arguments (unused). Returns: @@ -125,12 +132,20 @@ class RedisHistoryProvider(BaseHistoryProvider): messages.append(Message.from_dict(self._deserialize_json(serialized))) # type: ignore[union-attr] return messages - async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None: + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: """Persist messages for this session to Redis. Args: session_id: The session ID to store messages for. messages: The messages to persist. + state: Optional session state. Unused for Redis-backed history. **kwargs: Additional arguments (unused). """ if not messages: diff --git a/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py b/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py index 33748437e0..fa78a9ede5 100644 --- a/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py +++ b/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable -from agent_framework import AgentContext, AgentSession +from agent_framework import AgentContext, AgentSession, FunctionInvocationContext, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -18,9 +18,6 @@ sub-agent invoked as a tool using ``propagate_session=True``. When session propagation is enabled, both agents share the same session object, including session_id and the mutable state dict. This allows correlated conversation tracking and shared state across the agent hierarchy. - -The middleware functions below are purely for observability — they are NOT -required for session propagation to work. """ @@ -28,65 +25,83 @@ async def log_session( context: AgentContext, call_next: Callable[[], Awaitable[None]], ) -> None: - """Agent middleware that logs the session received by each agent. - - NOT required for session propagation — only used to observe the flow. - If propagation is working, both agents will show the same session_id. - """ + """Agent middleware that logs the session received by each agent.""" session: AgentSession | None = context.session + if not session: + print("No session found.") + await call_next() + return agent_name = context.agent.name or "unknown" - session_id = session.session_id if session else None - state = dict(session.state) if session else {} - print(f" [{agent_name}] session_id={session_id}, state={state}") + print( + f" [{agent_name}] session_id={session.session_id}, " + f"service_session_id={session.service_session_id} state={session.state}" + ) await call_next() +@tool(description="Use this tool to store the findings so that other agents can reason over them.") +def store_findings(findings: str, ctx: FunctionInvocationContext) -> None: + if ctx.session is None: + return + current_findings = ctx.session.state.get("findings") + if current_findings is None: + ctx.session.state["findings"] = findings + else: + ctx.session.state["findings"] = f"{current_findings}\n{findings}" + + +@tool(description="Use this tool to gather the current findings from other agents.") +def recall_findings(ctx: FunctionInvocationContext) -> str: + if ctx.session is None: + return "No session available" + current_findings = ctx.session.state.get("findings") + if current_findings is None: + return "Nothing yet" + return current_findings + + async def main() -> None: print("=== Agent-as-Tool: Session Propagation ===\n") client = OpenAIResponsesClient() - # --- Sub-agent: a research specialist --- - # The sub-agent has the same log_session middleware to prove it receives the session. research_agent = client.as_agent( name="ResearchAgent", - instructions="You are a research assistant. Provide concise answers.", + instructions="You are a research assistant. Provide concise answers and store your findings.", middleware=[log_session], + tools=[store_findings, recall_findings], ) - # propagate_session=True: the coordinator's session will be forwarded research_tool = research_agent.as_tool( name="research", - description="Research a topic and return findings", + description="Research a topic and store your findings.", arg_name="query", arg_description="The research query", propagate_session=True, ) - # --- Coordinator agent --- coordinator = client.as_agent( name="CoordinatorAgent", - instructions="You coordinate research. Use the 'research' tool to look up information.", - tools=[research_tool], + instructions=( + "You coordinate research. Use the 'research' tool to start research " + "and then use the recall findings tool to gather up everything." + ), + tools=[research_tool, store_findings, recall_findings], middleware=[log_session], ) - # Create a shared session and put some state in it session = coordinator.create_session() - session.state["request_source"] = "demo" + session.state["findings"] = None print(f"Session ID: {session.session_id}") print(f"Session state before run: {session.state}\n") - query = "What are the latest developments in quantum computing?" + query = "What are the latest developments in quantum computing and in AI?" print(f"User: {query}\n") result = await coordinator.run(query, session=session) print(f"\nCoordinator: {result}\n") print(f"Session state after run: {session.state}") - print( - "\nIf both agents show the same session_id above, session propagation is working." - ) if __name__ == "__main__": diff --git a/python/samples/02-agents/tools/function_tool_with_kwargs.py b/python/samples/02-agents/tools/function_tool_with_kwargs.py index 249ebc4a33..61db84eb17 100644 --- a/python/samples/02-agents/tools/function_tool_with_kwargs.py +++ b/python/samples/02-agents/tools/function_tool_with_kwargs.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -from typing import Annotated, Any +from typing import Annotated -from agent_framework import tool +from agent_framework import FunctionInvocationContext, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import Field @@ -14,27 +14,27 @@ load_dotenv() """ AI Function with kwargs Example -This example demonstrates how to inject custom keyword arguments (kwargs) into an AI function -from the agent's run method, without exposing them to the AI model. +This example demonstrates how to inject runtime context into an AI function +from the agent's run method, without exposing it to the AI model. This is useful for passing runtime information like access tokens, user IDs, or request-specific context that the tool needs but the model shouldn't know about -or provide. +or provide. The injected context parameter can be typed as +``FunctionInvocationContext`` as shown here, or left untyped as ``ctx`` when you +prefer a lighter-weight sample setup. """ -# Define the function tool with **kwargs to accept injected arguments -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# Define the function tool with explicit invocation context. +# The context parameter can also be declared as an untyped ``ctx`` parameter. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], - **kwargs: Any, + ctx: FunctionInvocationContext, ) -> str: """Get the weather for a given location.""" - # Extract the injected argument from kwargs - user_id = kwargs.get("user_id", "unknown") + # Extract the injected argument from the explicit context + user_id = ctx.kwargs.get("user_id", "unknown") # Simulate using the user_id for logging or personalization print(f"Getting weather for user: {user_id}") @@ -49,9 +49,11 @@ async def main() -> None: tools=[get_weather], ) - # Pass the injected argument when running the agent - # The 'user_id' kwarg will be passed down to the tool execution via **kwargs - response = await agent.run("What is the weather like in Amsterdam?", user_id="user_123") + # Pass the runtime context explicitly when running the agent. + response = await agent.run( + "What is the weather like in Amsterdam?", + function_invocation_kwargs={"user_id": "user_123"}, + ) print(f"Agent: {response.text}") diff --git a/python/samples/02-agents/tools/function_tool_with_session_injection.py b/python/samples/02-agents/tools/function_tool_with_session_injection.py index 2689ff5f9c..53cc63c2c0 100644 --- a/python/samples/02-agents/tools/function_tool_with_session_injection.py +++ b/python/samples/02-agents/tools/function_tool_with_session_injection.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -from typing import Annotated, Any +from typing import Annotated -from agent_framework import AgentSession, tool +from agent_framework import AgentSession, FunctionInvocationContext, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import Field @@ -14,23 +14,21 @@ load_dotenv() """ AI Function with Session Injection Example -This example demonstrates the behavior when passing 'session' to agent.run() -and accessing that session in AI function. +This example demonstrates accessing the agent session inside a tool function +via ``FunctionInvocationContext.session``. The session is automatically +available when the agent is invoked with a session. """ -# Define the function tool with **kwargs -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# Define the function tool with explicit invocation context. +# The context parameter can also be declared as an untyped parameter with the name: ``ctx``. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], - **kwargs: Any, + ctx: FunctionInvocationContext, ) -> str: """Get the weather for a given location.""" - # Get session object from kwargs - session = kwargs.get("session") + session = ctx.session if session and isinstance(session, AgentSession) and session.service_session_id: print(f"Session ID: {session.service_session_id}.") @@ -42,17 +40,19 @@ async def main() -> None: name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], - options={"store": True}, + default_options={"store": True}, ) # Create a session session = agent.create_session() - # Run the agent with the session - # Pass session via additional_function_arguments so tools can access it via **kwargs - opts = {"additional_function_arguments": {"session": session}} - print(f"Agent: {await agent.run('What is the weather in London?', session=session, options=opts)}") - print(f"Agent: {await agent.run('What is the weather in Amsterdam?', session=session, options=opts)}") + # Run the agent with the session; tools receive it via ctx.session. + print( + f"Agent: {await agent.run('What is the weather in London?', session=session)}" + ) + print( + f"Agent: {await agent.run('What is the weather in Amsterdam?', session=session)}" + ) print(f"Agent: {await agent.run('What cities did I ask about?', session=session)}") From 0009e330af988ea542b72d71aca473307fa70582 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:13:59 +0000 Subject: [PATCH 05/25] Fix hosted agent samples Docker build failures due to experimental API warnings (#4641) Add #pragma warning disable directives to suppress experimental API diagnostics that cause build errors in Docker isolation (where repo-level Directory.Build.props is not inherited): - AgentWithHostedMCP: suppress MEAI001 (HostedMcpServerTool) and OPENAI001 (GetResponsesClient) - FoundrySingleAgent: suppress CA2252 (AIProjectClient preview features) - FoundryMultiAgent: suppress CA2252 (AIProjectClient preview features) Fixes #4365 --- .../05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs | 3 +++ .../05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs | 2 ++ .../05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs | 2 ++ 3 files changed, 7 insertions(+) diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs index 972205cfe2..b7b610b663 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs @@ -4,6 +4,9 @@ // In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. // The sample demonstrates how to use MCP tools with auto approval by setting ApprovalMode to NeverRequire. +#pragma warning disable MEAI001 // HostedMcpServerTool, HostedMcpServerToolApprovalMode are experimental +#pragma warning disable OPENAI001 // GetResponsesClient is experimental + using Azure.AI.AgentServer.AgentFramework.Extensions; using Azure.AI.OpenAI; using Azure.Identity; diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs index 138efb0096..6947c85e3f 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs @@ -3,6 +3,8 @@ // This sample demonstrates a multi-agent workflow with Writer and Reviewer agents // using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder. +#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features + using Azure.AI.AgentServer.AgentFramework.Extensions; using Azure.AI.Projects; using Azure.Identity; diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs index 759636bcc0..80edf42089 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs @@ -4,6 +4,8 @@ // Uses Microsoft Agent Framework with Azure AI Foundry. // Ready for deployment to Foundry Hosted Agent service. +#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features + using System.ComponentModel; using System.Globalization; using System.Text; From 67b02828131f88517eda49ba055abc03c7a2c7b8 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:30:29 +0000 Subject: [PATCH 06/25] Bump rollup from 7.5.9 to 7.5.11 (#4688) --- .../packages/devui/frontend/package-lock.json | 8 ++- python/packages/devui/frontend/yarn.lock | 58 +++++++++---------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index 08db44a07b..4a43bcd90f 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -2509,6 +2509,8 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "inBundle": true, "license": "MIT", "optional": true, @@ -5019,9 +5021,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index 3aae3191a5..c0278b182e 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -169,24 +169,24 @@ "@babel/helper-validator-identifier" "^7.27.1" "@emnapi/core@^1.4.3", "@emnapi/core@^1.4.5": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" - integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + version "1.9.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.0.tgz#4a54213b208fcf288cce25076c74e0f7613e6100" + integrity sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w== dependencies: - "@emnapi/wasi-threads" "1.1.0" + "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.4.5": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" - integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + version "1.9.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.0.tgz#91c54a6e77c36154c125e873409472e2b70efd5b" + integrity sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw== dependencies: tslib "^2.4.0" -"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.0.4": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== +"@emnapi/wasi-threads@1.2.0", "@emnapi/wasi-threads@^1.0.4": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz#a19d9772cc3d195370bf6e2a805eec40aa75e18e" + integrity sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== dependencies: tslib "^2.4.0" @@ -212,7 +212,7 @@ "@esbuild/darwin-arm64@0.25.9": version "0.25.9" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz#f1513eaf9ec8fa15dcaf4c341b0f005d3e8b47ae" integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== "@esbuild/darwin-x64@0.25.9": @@ -317,7 +317,7 @@ "@esbuild/win32-x64@0.25.9": version "0.25.9" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz" integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": @@ -984,12 +984,12 @@ "@rollup/rollup-win32-x64-gnu@4.59.0": version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz#c4af3e9518c9a5cd4b1c163dc81d0ad4d82e7eab" + resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz" integrity sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA== "@rollup/rollup-win32-x64-msvc@4.59.0": version "4.59.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz#4584a8a87b29188a4c1fe987a9fcf701e256d86c" + resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz" integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== "@tailwindcss/node@4.1.12": @@ -1012,7 +1012,7 @@ "@tailwindcss/oxide-darwin-arm64@4.1.12": version "4.1.12" - resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz#e8bd4798f26ec1d012bf0683aeb77449f71505cd" integrity sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw== "@tailwindcss/oxide-darwin-x64@4.1.12": @@ -1069,7 +1069,7 @@ "@tailwindcss/oxide-win32-x64-msvc@4.1.12": version "4.1.12" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz#b1ee2ed0ef2c4095ddec3684a1987e2b3613af36" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz" integrity sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA== "@tailwindcss/oxide@4.1.12": @@ -1398,7 +1398,7 @@ brace-expansion@^1.1.7: brace-expansion@^2.0.2: version "2.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" @@ -1812,7 +1812,7 @@ flatted@^3.2.9: fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== gensync@^1.0.0-beta.2: @@ -1921,7 +1921,7 @@ js-tokens@^4.0.0: js-yaml@^4.1.0: version "4.1.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" @@ -1968,7 +1968,7 @@ levn@^0.4.1: lightningcss-darwin-arm64@1.30.1: version "1.30.1" - resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz#3d47ce5e221b9567c703950edf2529ca4a3700ae" integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ== lightningcss-darwin-x64@1.30.1: @@ -2013,7 +2013,7 @@ lightningcss-win32-arm64-msvc@1.30.1: lightningcss-win32-x64-msvc@1.30.1: version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352" + resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz" integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg== lightningcss@1.30.1: @@ -2080,14 +2080,14 @@ micromatch@^4.0.8: minimatch@^3.1.2: version "3.1.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz" integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" minimatch@^9.0.4: version "9.0.9" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz" integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== dependencies: brace-expansion "^2.0.2" @@ -2099,7 +2099,7 @@ minipass@^7.0.4, minipass@^7.1.2: minizlib@^3.1.0: version "3.1.0" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" + resolved "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz" integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== dependencies: minipass "^7.1.2" @@ -2267,7 +2267,7 @@ reusify@^1.0.4: rollup@^4.43.0: version "4.59.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" + resolved "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz" integrity sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== dependencies: "@types/estree" "1.0.8" @@ -2366,9 +2366,9 @@ tapable@^2.2.0: integrity sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg== tar@^7.4.3: - version "7.5.9" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.9.tgz#817ac12a54bc4362c51340875b8985d7dc9724b8" - integrity sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg== + version "7.5.11" + resolved "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" From 50fdcbaf57b606c9b8dd2f465fa5983824991378 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 13 Mar 2026 13:32:37 +0100 Subject: [PATCH 07/25] Python: chore(python): improve dependency range automation (#4343) * chore(python): improve dependency range automation - tighten dependency bounds and coding standards guidance\n- add dependency range validation workflow, reporting, and issue automation\n- update related tests and dependency pins for compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated text and pyarrow * new lock * fixed workflow * updated deps * fix tiktoken * chore(python): refine dependency validation workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(python): add high-level dependency validation comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WIP * added additional comments and excludes * added dev dependency handling and workflow and updates to package ranges * added readme and simplified commands * fix markers * chore(python): address dependency review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten dependency bounds, remove stale overrides, restore Python 3.10 support - Apply dependency bound policy across all packages: stable >=1.0 deps use >=floor,=3.10 with github-copilot-sdk gated behind python_version >= 3.11 marker; import raises ImportError on 3.10 - Skip github_copilot pyright/mypy/test tasks on Python <3.11 - Use version-conditional pyrightconfig for samples on Python 3.10 - Add compatibility fix in core responses client for older openai typed dicts - Normalize uv.lock prerelease mode and refresh dev dependencies - Update CODING_STANDARD.md, DEV_SETUP.md, and package management skill docs Closes #902 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * small tweaks * add note in workflow * fix workflows and several versions * fix duplicate --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../python-dependency-range-validation.yml | 216 +++ .../python-dev-dependency-upgrade.yml | 91 + .github/workflows/python-lab-tests.yml | 3 + .gitignore | 3 + .../skills/python-development/SKILL.md | 8 +- .../skills/python-package-management/SKILL.md | 44 +- python/CODING_STANDARD.md | 23 +- python/DEV_SETUP.md | 34 +- python/packages/a2a/pyproject.toml | 4 +- .../server/main.py | 16 +- python/packages/ag-ui/pyproject.toml | 12 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-ai/pyproject.toml | 8 +- python/packages/azure-cosmos/pyproject.toml | 2 +- python/packages/azurefunctions/pyproject.toml | 6 +- python/packages/bedrock/pyproject.toml | 2 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- .../agent_framework/_workflows/_workflow.py | 1 + .../openai/_responses_client.py | 2 +- python/packages/core/pyproject.toml | 24 +- .../azure/test_azure_embedding_client.py | 159 ++ .../openai/test_openai_embedding_client.py | 120 -- .../openai/test_openai_responses_client.py | 13 + python/packages/declarative/pyproject.toml | 6 +- python/packages/devui/pyproject.toml | 18 +- python/packages/durabletask/AGENTS.md | 14 +- python/packages/durabletask/README.md | 15 +- .../agent_framework_durabletask/_worker.py | 6 +- python/packages/durabletask/pyproject.toml | 10 +- python/packages/foundry_local/pyproject.toml | 4 +- .../agent_framework_github_copilot/_agent.py | 35 +- python/packages/github_copilot/pyproject.toml | 14 +- .../tests/test_github_copilot_agent.py | 5 + python/packages/lab/README.md | 21 + .../lab/gaia/agent_framework_lab_gaia/gaia.py | 55 +- .../agent_framework_lab_lightning/__init__.py | 10 +- .../lab/lightning/tests/test_lightning.py | 9 +- python/packages/lab/pyproject.toml | 34 +- .../lab/tau2/tests/test_tau2_utils.py | 84 +- python/packages/mem0/AGENTS.md | 14 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 2 +- python/packages/purview/pyproject.toml | 6 +- python/packages/redis/AGENTS.md | 14 +- python/packages/redis/README.md | 6 +- python/packages/redis/pyproject.toml | 8 +- python/pyproject.toml | 101 +- python/pyrightconfig.samples.py310.json | 17 + python/scripts/__init__.py | 5 + python/scripts/dependencies/README.md | 95 + python/scripts/dependencies/__init__.py | 0 .../_dependency_bounds_lower_impl.py | 1097 +++++++++++ .../_dependency_bounds_runtime.py | 86 + .../_dependency_bounds_upper_impl.py | 1275 ++++++++++++ .../dependencies/upgrade_dev_dependencies.py | 180 ++ .../validate_dependency_bounds.py | 490 +++++ python/uv.lock | 1724 +++++++++++++---- 61 files changed, 5500 insertions(+), 779 deletions(-) create mode 100644 .github/workflows/python-dependency-range-validation.yml create mode 100644 .github/workflows/python-dev-dependency-upgrade.yml create mode 100644 python/packages/core/tests/azure/test_azure_embedding_client.py create mode 100644 python/pyrightconfig.samples.py310.json create mode 100644 python/scripts/__init__.py create mode 100644 python/scripts/dependencies/README.md create mode 100644 python/scripts/dependencies/__init__.py create mode 100644 python/scripts/dependencies/_dependency_bounds_lower_impl.py create mode 100644 python/scripts/dependencies/_dependency_bounds_runtime.py create mode 100644 python/scripts/dependencies/_dependency_bounds_upper_impl.py create mode 100644 python/scripts/dependencies/upgrade_dev_dependencies.py create mode 100644 python/scripts/dependencies/validate_dependency_bounds.py diff --git a/.github/workflows/python-dependency-range-validation.yml b/.github/workflows/python-dependency-range-validation.yml new file mode 100644 index 0000000000..2f01552796 --- /dev/null +++ b/.github/workflows/python-dependency-range-validation.yml @@ -0,0 +1,216 @@ +# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates. +name: Python - Dependency Range Validation + +on: + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +env: + UV_CACHE_DIR: /tmp/.uv-cache + +jobs: + dependency-range-validation: + name: Dependency Range Validation + runs-on: ubuntu-latest + env: + # For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything) + # then we will have to reevaluate. + UV_PYTHON: "3.13" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + env: + UV_CACHE_DIR: /tmp/.uv-cache + + - name: Run dependency range validation + id: validate_ranges + # Keep workflow running so we can still publish diagnostics from this run. + continue-on-error: true + run: uv run poe validate-dependency-bounds-project --mode upper --project "*" + working-directory: ./python + + - name: Upload dependency range report + # Always publish the report so failures are inspectable even when validation fails. + if: always() + uses: actions/upload-artifact@v4 + with: + name: dependency-range-results + path: python/scripts/dependencies/dependency-range-results.json + if-no-files-found: warn + + - name: Create issues for failed dependency candidates + # Always process the report so failed candidates create actionable tracking issues. + if: always() + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs") + const reportPath = "python/scripts/dependencies/dependency-range-results.json" + + if (!fs.existsSync(reportPath)) { + core.warning(`No dependency range report found at ${reportPath}`) + return + } + + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) + const dependencyFailures = [] + + for (const packageResult of report.packages ?? []) { + for (const dependency of packageResult.dependencies ?? []) { + const candidateVersions = new Set(dependency.candidate_versions ?? []) + const failedAttempts = (dependency.attempts ?? []).filter( + (attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper) + ) + if (!failedAttempts.length) { + continue + } + + const failuresByVersion = new Map() + for (const attempt of failedAttempts) { + const version = attempt.trial_upper || "unknown" + if (!failuresByVersion.has(version)) { + failuresByVersion.set(version, attempt.error || "No error output captured.") + } + } + + dependencyFailures.push({ + packageName: packageResult.package_name, + projectPath: packageResult.project_path, + dependencyName: dependency.name, + originalRequirements: dependency.original_requirements ?? [], + finalRequirements: dependency.final_requirements ?? [], + failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })), + }) + } + } + + if (!dependencyFailures.length) { + core.info("No failing dependency candidates found.") + return + } + + const owner = context.repo.owner + const repo = context.repo.repo + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }) + const openIssueTitles = new Set( + openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) + ) + + const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") + + for (const failure of dependencyFailures) { + const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})` + if (openIssueTitles.has(title)) { + core.info(`Issue already exists: ${title}`) + continue + } + + const visibleFailures = failure.failedVersions.slice(0, 5) + const omittedCount = failure.failedVersions.length - visibleFailures.length + const failureDetails = visibleFailures + .map( + (entry) => + `- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\`` + ) + .join("\n\n") + + const body = [ + "Automated dependency range validation found candidate versions that failed checks.", + "", + `- Package: \`${failure.packageName}\``, + `- Project path: \`${failure.projectPath}\``, + `- Dependency: \`${failure.dependencyName}\``, + `- Original requirements: ${ + failure.originalRequirements.length + ? failure.originalRequirements.map((value) => `\`${value}\``).join(", ") + : "_none_" + }`, + `- Final requirements after run: ${ + failure.finalRequirements.length + ? failure.finalRequirements.map((value) => `\`${value}\``).join(", ") + : "_none_" + }`, + "", + "### Failed versions and errors", + failureDetails, + omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "", + "", + `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`, + ].join("\n") + + await github.rest.issues.create({ + owner, + repo, + title, + body, + }) + openIssueTitles.add(title) + core.info(`Created issue: ${title}`) + } + + - name: Refresh lockfile + # Only refresh lockfile after a clean validation to avoid committing known-bad ranges. + if: steps.validate_ranges.outcome == 'success' + run: uv lock --upgrade + working-directory: ./python + + - name: Commit and push dependency updates + id: commit_updates + if: steps.validate_ranges.outcome == 'success' + run: | + BRANCH="automation/python-dependency-range-updates" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "${BRANCH}" + + git add python/packages/*/pyproject.toml python/uv.lock + if git diff --cached --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No dependency updates to commit." + exit 0 + fi + + git commit -m "chore: update dependency ranges" + git push --force-with-lease --set-upstream origin "${BRANCH}" + echo "has_changes=true" >> "$GITHUB_OUTPUT" + + - name: Create or update pull request with GitHub CLI + # Only open/update PRs for validated updates to keep automation branches trustworthy. + if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true' + run: | + BRANCH="automation/python-dependency-range-updates" + PR_TITLE="Python: chore: update dependency ranges" + PR_BODY_FILE="$(mktemp)" + + cat > "${PR_BODY_FILE}" <<'EOF' + This PR was generated by the dependency range validation workflow. + + - Ran `uv run poe validate-dependency-bounds-project --mode upper --project "*"` + - Updated package dependency bounds + - Refreshed `python/uv.lock` with `uv lock --upgrade` + EOF + + PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" + if [ -n "${PR_NUMBER}" ]; then + gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + else + gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + fi diff --git a/.github/workflows/python-dev-dependency-upgrade.yml b/.github/workflows/python-dev-dependency-upgrade.yml new file mode 100644 index 0000000000..0dcd138b25 --- /dev/null +++ b/.github/workflows/python-dev-dependency-upgrade.yml @@ -0,0 +1,91 @@ +name: Python - Dev Dependency Upgrade + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +env: + UV_CACHE_DIR: /tmp/.uv-cache + +jobs: + upgrade-dev-dependencies: + name: Upgrade Dev Dependencies + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.13" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + env: + UV_CACHE_DIR: /tmp/.uv-cache + + - name: Upgrade dev dependencies and validate workspace + run: uv run poe upgrade-dev-dependencies + working-directory: ./python + + - name: Commit and push dev dependency updates + id: commit_updates + run: | + BRANCH="automation/python-dev-dependency-updates" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "${BRANCH}" + + git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock + if git diff --cached --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No dev dependency updates to commit." + exit 0 + fi + + git commit -F- <<'EOF' + Python: chore: upgrade dev dependencies + EOF + git push --force-with-lease --set-upstream origin "${BRANCH}" + echo "has_changes=true" >> "$GITHUB_OUTPUT" + + - name: Create or update pull request with GitHub CLI + if: steps.commit_updates.outputs.has_changes == 'true' + run: | + BRANCH="automation/python-dev-dependency-updates" + PR_TITLE="Python: chore: upgrade dev dependencies" + PR_BODY_FILE="$(mktemp)" + + cat > "${PR_BODY_FILE}" <<'EOF' + ### Motivation and Context + + This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request. + + ### Description + + - Ran `uv run poe upgrade-dev-dependencies` + - Refreshed dev dependency pins in workspace `pyproject.toml` files + - Refreshed `python/uv.lock` with `uv lock --upgrade` + - Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test` + + ### Contribution Checklist + + - [x] The code builds clean without any errors or warnings + - [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md) + - [x] All unit tests pass, and I have added new tests where possible + - [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR. + EOF + + PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" + if [ -n "${PR_NUMBER}" ]; then + gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + else + gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + fi diff --git a/.github/workflows/python-lab-tests.yml b/.github/workflows/python-lab-tests.yml index c8ed926dd4..0c11cf1a58 100644 --- a/.github/workflows/python-lab-tests.yml +++ b/.github/workflows/python-lab-tests.yml @@ -76,6 +76,9 @@ jobs: - name: Run lab tests run: cd packages/lab && uv run poe test + - name: Run resource-intensive lab tests + run: cd packages/lab && uv run pytest -m "resource_intensive and not integration" --junitxml=test-results-resource-intensive.xml + - name: Run lab lint run: cd packages/lab && uv run poe lint diff --git a/.gitignore b/.gitignore index 09b8dfa453..4dd5848e89 100644 --- a/.gitignore +++ b/.gitignore @@ -205,6 +205,9 @@ WARP.md **/memory-bank/ **/projectBrief.md **/tmpclaude* +# Dependency-bound validation reports +python/scripts/dependency-*-results.json +python/scripts/dependencies/dependency-*-results.json # Azurite storage emulator files */__azurite_db_blob__.json* diff --git a/python/.github/skills/python-development/SKILL.md b/python/.github/skills/python-development/SKILL.md index ad34c2561c..ca73bd8ada 100644 --- a/python/.github/skills/python-development/SKILL.md +++ b/python/.github/skills/python-development/SKILL.md @@ -69,7 +69,7 @@ def equal(arg1: str, arg2: str) -> bool: ```python # Core -from agent_framework import ChatAgent, Message, tool +from agent_framework import Agent, Message, tool # Components from agent_framework.observability import enable_instrumentation @@ -82,16 +82,16 @@ from agent_framework.azure import AzureOpenAIChatClient ## Public API and Exports In `__init__.py` files that define package-level public APIs, use direct re-export imports plus an explicit -`__all__`. Avoid identity aliases like `from ._agents import ChatAgent as ChatAgent`, and avoid +`__all__`. Avoid identity aliases like `from ._agents import Agent as Agent`, and avoid `from module import *`. Do not define `__all__` in internal non-`__init__.py` modules. Exception: modules intentionally exposed as a public import surface (for example, `agent_framework.observability`) should define `__all__`. ```python -__all__ = ["ChatAgent", "Message", "ChatResponse"] +__all__ = ["Agent", "Message", "ChatResponse"] -from ._agents import ChatAgent +from ._agents import Agent from ._types import Message, ChatResponse ``` diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 8784aed453..e954480e75 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -33,13 +33,44 @@ Uses [uv](https://github.com/astral-sh/uv) for dependency management and # Full setup (venv + install + prek hooks) uv run poe setup -# Install/update all dependencies +# Install dependencies from lockfile (frozen resolution with prerelease policy) uv run poe install # Create venv with specific Python version uv run poe venv --python 3.12 + +# Intentionally upgrade a specific dependency to reduce lockfile conflicts +uv lock --upgrade-package && uv run poe install + +# Refresh all dev dependency pins, lockfile, and validation in one run +uv run poe upgrade-dev-dependencies + +# First, run workspace-wide lower/upper compatibility gates +uv run poe validate-dependency-bounds-test +# Defaults to --project "*"; pass a package to scope test mode +uv run poe validate-dependency-bounds-test --project + +# Then expand bounds for one dependency in the target package +uv run poe validate-dependency-bounds-project --mode both --project --dependency "" + +# Repo-wide automation can reuse the same task +uv run poe validate-dependency-bounds-project --mode upper --project "*" + +# Add a dependency to one project and run both validators for that project/dependency +uv run poe add-dependency-and-validate-bounds --project --dependency "" ``` +### Dependency Bound Notes + +- Stable dependencies (`>=1.0`) should typically be bounded as `>=,`. +- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges). +- For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible. +- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths. +- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--project "*"` and omitting `--dependency`. +- Prefer targeted lock updates with `uv lock --upgrade-package ` to reduce `uv.lock` merge conflicts. +- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command. +- Use `upgrade-dev-dependencies` for repo-wide dev tooling refreshes; it repins dev dependencies, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`. + ## Lazy Loading Pattern Provider folders in core use `__getattr__` to lazy load from connector packages: @@ -74,6 +105,17 @@ def __getattr__(name: str) -> Any: 4. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml` 5. Do **NOT** create lazy loading in core yet +Recommended dependency workflow during connector implementation: + +1. Add the dependency to the target package: + `uv run poe add-dependency-to-project --project --dependency ""` +2. Implement connector code and tests. +3. Validate dependency bounds for that package/dependency: + `uv run poe validate-dependency-bounds-project --mode both --project --dependency ""` +4. If the package has meaningful tests/checks that validate dependency compatibility, you can use the add + validation flow in one command: + `uv run poe add-dependency-and-validate-bounds --project --dependency ""` + If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation. + ### Promotion to Stable 1. Move samples to root `samples/` folder diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index 8611592692..be894dc545 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -165,10 +165,14 @@ user_msg = Message("user", ["Hello, world!"]) asst_msg = Message("assistant", ["Hello, world!"]) # ❌ Not preferred - unnecessary inheritance -from agent_framework import UserMessage, AssistantMessage +class UserMessage(Message): + pass -user_msg = UserMessage(content="Hello, world!") -asst_msg = AssistantMessage(content="Hello, world!") +class AssistantMessage(Message): + pass + +user_msg = UserMessage("user", ["Hello, world!"]) +asst_msg = AssistantMessage("assistant", ["Hello, world!"]) ``` ### Import Structure @@ -388,6 +392,19 @@ All non-core packages declare a lower bound on `agent-framework-core` (e.g., `"a - **Core version changes**: When `agent-framework-core` is updated with breaking or significant changes and its version is bumped, update the `agent-framework-core>=...` lower bound in every other package's `pyproject.toml` to match the new core version. - **Non-core version changes**: Non-core packages (connectors, extensions) can have their own versions incremented independently while keeping the existing core lower bound pinned. Only raise the core lower bound if the non-core package actually depends on new core APIs. +### External Dependency Version Bounds + +The guiding principle for external dependencies is to make the range of allowed versions as broad as possible, even if that means we have to do some conditional imports, and other tricks to allow small changes in versions. +So we use bounded ranges for external package dependencies in `pyproject.toml`: + + +- For stable dependencies (`>=1.0.0`), use a lower bound at a known-good version and an explicit upper bound that reflects the maximum major version we currently support (for example: `openai>=1.99.0,<3`). +- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good lower bound with a hard upper boundary in the same prerelease line (for example: `azure-ai-projects>=2.0.0b3,<2.0.0b4`). +- For `<1.0.0` dependencies, use a known-good bounded range with an explicit upper cap. Prefer the broadest validated range the package can actually support: that may be a patch line, a minor line, or multiple minor lines (for example: `a2a-sdk>=0.3.5,<0.4.0`, `fastapi>=0.115.0,<0.136.0`, `uvicorn>=0.30.0,<0.39.0`). +- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good bounded range with a hard upper cap and keep the range only as broad as the package's validation coverage justifies. +- Prefer keeping support for multiple major versions when practical. This may mean that the upper bound spans multiple major versions when the dependency maintains backward compatibility; if APIs differ between supported majors, version-conditional imports/branches are acceptable to preserve compatibility. +- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --project --dependency ""` to expand package-scoped bounds. + ### Installation Options Connectors are distributed as separate packages and are not imported by default in the core package. Users install the specific connectors they need: diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index 3769a5df9e..fa6619b899 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -217,10 +217,13 @@ uv run poe setup --python 3.12 ``` #### `install` -Install all dependencies including extras and dev dependencies, including updates: +Install all dependencies (including extras and dev dependencies) from the lockfile using frozen resolution: ```bash uv run poe install ``` +For intentional dependency upgrades, run `uv lock --upgrade-package ` and then run `uv run poe install`. + +For repo-wide dev tooling refreshes, run `uv run poe upgrade-dev-dependencies` to repin dev dependencies, refresh `uv.lock`, and rerun validation, typing, and tests. #### `venv` Create a virtual environment with specified Python version or switch python version: @@ -278,6 +281,35 @@ Lint markdown code blocks: uv run poe markdown-code-lint ``` +#### `validate-dependency-bounds-test` +Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure: +```bash +uv run poe validate-dependency-bounds-test +# Defaults to --project "*"; pass a package to scope test mode +uv run poe validate-dependency-bounds-test --project +``` + +#### `validate-dependency-bounds-project` +Validate and extend dependency bounds for a single dependency in a single package. Use `--mode lower`, `--mode upper`, or the default `--mode both`: +```bash +uv run poe validate-dependency-bounds-project --mode both --project --dependency "" +``` +`--project` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --project "*"` to run the upper-bound pass across the workspace. +For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may still be a single patch or minor line, but multi-minor ranges are fine when the package's checks/tests prove they work. + +#### `add-dependency-and-validate-bounds` +Add an external dependency to a workspace project and run both validators for that same project/dependency: +```bash +uv run poe add-dependency-and-validate-bounds --project --dependency "" +``` + +#### `upgrade-dev-dependencies` +Refresh exact dev dependency pins across the workspace, run `uv lock --upgrade`, reinstall from the frozen lockfile, then rerun validation, typing, and tests: +```bash +uv run poe upgrade-dev-dependencies +``` +Use this for repo-wide dev tooling refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package ` plus the package-scoped bound validation tasks above. + ### Comprehensive Checks #### `check-packages` diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 4d015305c7..fece606606 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "a2a-sdk>=0.3.5", + "a2a-sdk>=0.3.5,<0.3.24", ] [tool.uv] @@ -87,7 +87,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a" -test = "pytest -m \"not integration\" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index 5ea275b5fd..b422d70c8e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -6,13 +6,12 @@ from __future__ import annotations import logging import os -from typing import cast +from typing import Any, cast import uvicorn from agent_framework import ChatOptions from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.anthropic import AnthropicClient from agent_framework.azure import AzureOpenAIChatClient from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -26,6 +25,15 @@ from ..agents.task_steps_agent import task_steps_agent_wrapped from ..agents.ui_generator_agent import ui_generator_agent from ..agents.weather_agent import weather_agent +AnthropicClient: type[Any] | None +try: + import agent_framework.anthropic as _anthropic_namespace +except ImportError: + # If the Anthropic client isn't installed, we can still run the server with Azure OpenAI as the default chat client + AnthropicClient = None +else: + AnthropicClient = cast(type[Any] | None, getattr(_anthropic_namespace, "AnthropicClient", None)) + # Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable) if os.getenv("ENABLE_DEBUG_LOGGING"): log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log") @@ -70,7 +78,9 @@ app.add_middleware( # Set CHAT_CLIENT=anthropic to use Anthropic, defaults to Azure OpenAI client: SupportsChatGetResponse[ChatOptions] = cast( SupportsChatGetResponse[ChatOptions], - AnthropicClient() if os.getenv("CHAT_CLIENT", "").lower() == "anthropic" else AzureOpenAIChatClient(), + AnthropicClient() + if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic" + else AzureOpenAIChatClient(), ) # Agentic Chat - basic chat agent diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 355405142e..74b04669b4 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -23,15 +23,15 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "ag-ui-protocol>=0.1.9", - "fastapi>=0.115.0", - "uvicorn>=0.30.0" + "ag-ui-protocol==0.1.13", + "fastapi>=0.115.0,<0.133.1", + "uvicorn[standard]>=0.30.0,<0.42.0" ] [project.optional-dependencies] dev = [ - "pytest>=8.0.0", - "httpx>=0.27.0", + "pytest==9.0.2", + "httpx==0.28.1", ] [build-system] @@ -74,4 +74,4 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui" -test = "pytest -m \"not integration\" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui" +test = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui' diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 95be433e5a..92522a9c50 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "anthropic>=0.70.0,<1", + "anthropic>=0.80.0,<0.80.1", ] [tool.uv] @@ -87,7 +87,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic" -test = "pytest -m \"not integration\" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests" +test = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index d391de0d93..66b5689acf 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "azure-search-documents==11.7.0b2", + "azure-search-documents>=11.7.0b2,<11.7.0b3", ] [tool.uv] @@ -89,7 +89,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search" -test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 0df9533a0b..76c37fdbea 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -24,9 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "azure-ai-agents == 1.2.0b5", - "azure-ai-inference>=1.0.0b9", - "aiohttp", + "azure-ai-agents>=1.2.0b5,<1.2.0b6", + "azure-ai-inference>=1.0.0b9,<1.0.0b10", + "aiohttp>=3.7.0,<4", ] [tool.uv] @@ -87,7 +87,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai" -test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests' [tool.poe.tasks.integration-tests] cmd = """ diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 24ffbf8886..9566c53c09 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "azure-cosmos>=4.9.0", + "azure-cosmos>=4.3.0,<5", ] [tool.uv] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index c9e7890ede..78b38541dd 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -24,8 +24,8 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.0.0rc4", "agent-framework-durabletask", - "azure-functions", - "azure-functions-durable", + "azure-functions>=1.24.0,<2", + "azure-functions-durable>=1.3.1,<2", ] [dependency-groups] @@ -93,7 +93,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions" -test = "pytest -m \"not integration\" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index 4f1db9f4f3..201ae0e80f 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -86,7 +86,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock" -test = "pytest -m \"not integration\" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests' [build-system] requires = ["hatchling"] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index d6fa2bb382..9bb2bdbce3 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "openai-chatkit>=1.4.0,<2.0.0", + "openai-chatkit>=1.4.1,<2.0.0", ] [tool.uv] @@ -88,7 +88,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit" -test = "pytest -m \"not integration\" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 2f67d8d947..1ee7e0cd50 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "claude-agent-sdk>=0.1.25", + "claude-agent-sdk>=0.1.36,<0.1.49", ] [tool.uv] @@ -88,7 +88,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude" -test = "pytest -m \"not integration\" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index c6d382b923..8756ec40bd 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "microsoft-agents-copilotstudio-client>=0.3.1", + "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] [tool.uv] @@ -87,7 +87,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio" -test = "pytest -m \"not integration\" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 8c6b5fe1fb..cf030bf7b0 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: RUF070, RUF100 from __future__ import annotations import asyncio diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 145986fb9a..2021eec603 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -665,7 +665,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] if output_format: tool["output_format"] = output_format if model: - tool["model"] = model + tool["model"] = model # type: ignore if quality: tool["quality"] = quality if partial_images is not None: diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 7b63f69d1e..e1c79ddb53 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -24,19 +24,19 @@ classifiers = [ ] dependencies = [ # utilities - "typing-extensions", + "typing-extensions>=4.15.0,<5", "pydantic>=2,<3", "python-dotenv>=1,<2", # telemetry - "opentelemetry-api>=1.39.0", - "opentelemetry-sdk>=1.39.0", - "opentelemetry-semantic-conventions-ai>=0.4.13", + "opentelemetry-api>=1.39.0,<2", + "opentelemetry-sdk>=1.39.0,<2", + "opentelemetry-semantic-conventions-ai>=0.4.13,<0.4.14", # connectors and functions - "openai>=1.99.0", + "openai>=1.99.0,<3", "azure-identity>=1,<2", "azure-ai-projects>=2.0.0,<3.0", "mcp[ws]>=1.24.0,<2", - "packaging>=24.1", + "packaging>=24.1,<25", ] [project.optional-dependencies] @@ -76,15 +76,7 @@ environments = [ fallback-version = "0.0.0" [tool.pytest.ini_options] -testpaths = [ - 'tests', - 'packages/core/tests', - 'packages/a2a/tests', - 'packages/azure-ai/tests', - 'packages/copilotstudio/tests', - 'packages/mem0/tests', - 'packages/runtime/tests' -] +testpaths = ['tests'] addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" @@ -131,7 +123,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework" -test = "pytest -m \"not integration\" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests" +test = 'pytest -m "not integration" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' [tool.flit.module] name = "agent_framework" diff --git a/python/packages/core/tests/azure/test_azure_embedding_client.py b/python/packages/core/tests/azure/test_azure_embedding_client.py new file mode 100644 index 0000000000..97e477549c --- /dev/null +++ b/python/packages/core/tests/azure/test_azure_embedding_client.py @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest +from openai.types import CreateEmbeddingResponse +from openai.types import Embedding as OpenAIEmbedding +from openai.types.create_embedding_response import Usage + +from agent_framework.azure import AzureOpenAIEmbeddingClient +from agent_framework.openai import OpenAIEmbeddingOptions + + +def _make_openai_response( + embeddings: list[list[float]], + model: str = "text-embedding-3-small", + prompt_tokens: int = 5, + total_tokens: int = 5, +) -> CreateEmbeddingResponse: + """Helper to create a mock OpenAI embeddings response.""" + data = [OpenAIEmbedding(embedding=emb, index=i, object="embedding") for i, emb in enumerate(embeddings)] + return CreateEmbeddingResponse( + data=data, + model=model, + object="list", + usage=Usage(prompt_tokens=prompt_tokens, total_tokens=total_tokens), + ) + + +@pytest.fixture +def azure_embedding_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear ambient Azure OpenAI embedding env vars for deterministic unit tests.""" + for key in ( + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_TOKEN_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + +def test_azure_construction_with_deployment_name(azure_embedding_unit_test_env: None) -> None: + client = AzureOpenAIEmbeddingClient( + deployment_name="text-embedding-3-small", + api_key="test-key", + endpoint="https://test.openai.azure.com/", + ) + assert client.model_id == "text-embedding-3-small" + + +def test_azure_construction_with_existing_client(azure_embedding_unit_test_env: None) -> None: + mock_client = MagicMock() + client = AzureOpenAIEmbeddingClient( + deployment_name="my-deployment", + async_client=mock_client, + ) + assert client.model_id == "my-deployment" + assert client.client is mock_client + + +def test_azure_construction_missing_deployment_name_raises(azure_embedding_unit_test_env: None) -> None: + with pytest.raises(ValueError, match="deployment name is required"): + AzureOpenAIEmbeddingClient( + api_key="test-key", + endpoint="https://test.openai.azure.com/", + ) + + +def test_azure_construction_missing_credentials_raises(azure_embedding_unit_test_env: None) -> None: + with pytest.raises(ValueError, match="api_key, credential, or a client"): + AzureOpenAIEmbeddingClient( + deployment_name="test", + endpoint="https://test.openai.azure.com/", + ) + + +async def test_azure_get_embeddings(azure_embedding_unit_test_env: None) -> None: + mock_response = _make_openai_response( + embeddings=[[0.1, 0.2]], + ) + mock_async_client = MagicMock() + mock_async_client.embeddings = MagicMock() + mock_async_client.embeddings.create = AsyncMock(return_value=mock_response) + + client = AzureOpenAIEmbeddingClient( + deployment_name="text-embedding-3-small", + async_client=mock_async_client, + ) + + result = await client.get_embeddings(["hello"]) + + assert len(result) == 1 + assert result[0].vector == [0.1, 0.2] + + +def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None: + mock_client = MagicMock() + client = AzureOpenAIEmbeddingClient( + deployment_name="test", + async_client=mock_client, + ) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + + +skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( + not os.getenv("AZURE_OPENAI_ENDPOINT") + or (not os.getenv("AZURE_OPENAI_API_KEY") and not os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME")), + reason="No Azure OpenAI credentials provided; skipping integration tests.", +) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_azure_openai_get_embeddings() -> None: + """End-to-end test of Azure OpenAI embedding generation.""" + client = AzureOpenAIEmbeddingClient() + + result = await client.get_embeddings(["hello world"]) + + assert len(result) == 1 + assert isinstance(result[0].vector, list) + assert len(result[0].vector) > 0 + assert all(isinstance(v, float) for v in result[0].vector) + assert result[0].model_id is not None + assert result.usage is not None + assert result.usage["input_token_count"] > 0 + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_azure_openai_get_embeddings_multiple() -> None: + """Test Azure OpenAI embedding generation for multiple inputs.""" + client = AzureOpenAIEmbeddingClient() + + result = await client.get_embeddings(["hello", "world", "test"]) + + assert len(result) == 3 + dims = [len(e.vector) for e in result] + assert all(d == dims[0] for d in dims) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: + """Test Azure OpenAI embedding generation with custom dimensions.""" + client = AzureOpenAIEmbeddingClient() + + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) + + assert len(result) == 1 + assert len(result[0].vector) == 256 diff --git a/python/packages/core/tests/openai/test_openai_embedding_client.py b/python/packages/core/tests/openai/test_openai_embedding_client.py index 3ddb7538a6..72c7e4121d 100644 --- a/python/packages/core/tests/openai/test_openai_embedding_client.py +++ b/python/packages/core/tests/openai/test_openai_embedding_client.py @@ -10,7 +10,6 @@ from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage -from agent_framework.azure import AzureOpenAIEmbeddingClient from agent_framework.openai import ( OpenAIEmbeddingClient, OpenAIEmbeddingOptions, @@ -190,73 +189,6 @@ async def test_openai_empty_values_returns_empty(openai_unit_test_env: None) -> client.client.embeddings.create.assert_not_called() -# --- Azure OpenAI unit tests --- - - -def test_azure_construction_with_deployment_name() -> None: - client = AzureOpenAIEmbeddingClient( - deployment_name="text-embedding-3-small", - api_key="test-key", - endpoint="https://test.openai.azure.com/", - ) - assert client.model_id == "text-embedding-3-small" - - -def test_azure_construction_with_existing_client() -> None: - mock_client = MagicMock() - client = AzureOpenAIEmbeddingClient( - deployment_name="my-deployment", - async_client=mock_client, - ) - assert client.model_id == "my-deployment" - assert client.client is mock_client - - -def test_azure_construction_missing_deployment_name_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) - with pytest.raises(ValueError, match="deployment name is required"): - AzureOpenAIEmbeddingClient( - api_key="test-key", - endpoint="https://test.openai.azure.com/", - ) - - -def test_azure_construction_missing_credentials_raises() -> None: - with pytest.raises(ValueError, match="api_key, credential, or a client"): - AzureOpenAIEmbeddingClient( - deployment_name="test", - endpoint="https://test.openai.azure.com/", - ) - - -async def test_azure_get_embeddings() -> None: - mock_response = _make_openai_response( - embeddings=[[0.1, 0.2]], - ) - mock_async_client = MagicMock() - mock_async_client.embeddings = MagicMock() - mock_async_client.embeddings.create = AsyncMock(return_value=mock_response) - - client = AzureOpenAIEmbeddingClient( - deployment_name="text-embedding-3-small", - async_client=mock_async_client, - ) - - result = await client.get_embeddings(["hello"]) - - assert len(result) == 1 - assert result[0].vector == [0.1, 0.2] - - -def test_azure_otel_provider_name() -> None: - mock_client = MagicMock() - client = AzureOpenAIEmbeddingClient( - deployment_name="test", - async_client=mock_client, - ) - assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" - - # --- Integration tests --- skip_if_openai_integration_tests_disabled = pytest.mark.skipif( @@ -264,12 +196,6 @@ skip_if_openai_integration_tests_disabled = pytest.mark.skipif( reason="No real OPENAI_API_KEY provided; skipping integration tests.", ) -skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( - not os.getenv("AZURE_OPENAI_ENDPOINT") - or (not os.getenv("AZURE_OPENAI_API_KEY") and not os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME")), - reason="No Azure OpenAI credentials provided; skipping integration tests.", -) - @skip_if_openai_integration_tests_disabled @pytest.mark.flaky @@ -315,49 +241,3 @@ async def test_integration_openai_get_embeddings_with_dimensions() -> None: assert len(result) == 1 assert len(result[0].vector) == 256 - - -@skip_if_azure_openai_integration_tests_disabled -@pytest.mark.flaky -@pytest.mark.integration -async def test_integration_azure_openai_get_embeddings() -> None: - """End-to-end test of Azure OpenAI embedding generation.""" - client = AzureOpenAIEmbeddingClient() - - result = await client.get_embeddings(["hello world"]) - - assert len(result) == 1 - assert isinstance(result[0].vector, list) - assert len(result[0].vector) > 0 - assert all(isinstance(v, float) for v in result[0].vector) - assert result[0].model_id is not None - assert result.usage is not None - assert result.usage["input_token_count"] > 0 - - -@skip_if_azure_openai_integration_tests_disabled -@pytest.mark.flaky -@pytest.mark.integration -async def test_integration_azure_openai_get_embeddings_multiple() -> None: - """Test Azure OpenAI embedding generation for multiple inputs.""" - client = AzureOpenAIEmbeddingClient() - - result = await client.get_embeddings(["hello", "world", "test"]) - - assert len(result) == 3 - dims = [len(e.vector) for e in result] - assert all(d == dims[0] for d in dims) - - -@skip_if_azure_openai_integration_tests_disabled -@pytest.mark.flaky -@pytest.mark.integration -async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: - """Test Azure OpenAI embedding generation with custom dimensions.""" - client = AzureOpenAIEmbeddingClient() - - options: OpenAIEmbeddingOptions = {"dimensions": 256} - result = await client.get_embeddings(["hello world"], options=options) - - assert len(result) == 1 - assert len(result[0].vector) == 256 diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 696dd77772..9506c8ec47 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1876,6 +1876,19 @@ def test_prepare_tools_for_openai_with_image_generation_options() -> None: assert image_tool["quality"] == "high" +def test_prepare_tools_for_openai_with_custom_image_generation_model() -> None: + """Test image generation tool conversion with a custom model string.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + tool = OpenAIResponsesClient.get_image_generation_tool(model="custom-image-model") + + resp_tools = client._prepare_tools_for_openai([tool]) + assert len(resp_tools) == 1 + image_tool = resp_tools[0] + assert image_tool["type"] == "image_generation" + assert image_tool["model"] == "custom-image-model" + + def test_parse_chunk_from_openai_with_mcp_approval_request() -> None: """Test that a streaming mcp_approval_request event is parsed into FunctionApprovalRequestContent.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index c16df02fea..8eff06022b 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -23,12 +23,12 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "powerfx>=0.0.31; python_version < '3.14'", + "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] [dependency-groups] dev = [ - "types-PyYaml" + "types-PyYaml==6.0.12.20250915" ] [tool.uv] @@ -94,7 +94,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative" -test = "pytest -m \"not integration\" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index d00ad90aba..396dd22e5d 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -24,14 +24,20 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "fastapi>=0.104.0", - "uvicorn[standard]>=0.24.0", - "python-dotenv>=1.0.0", + "fastapi>=0.115.0,<0.133.1", + "uvicorn[standard]>=0.30.0,<0.42.0" ] [project.optional-dependencies] -dev = ["pytest>=7.0.0", "watchdog>=3.0.0", "agent-framework-orchestrations"] -all = ["pytest>=7.0.0", "watchdog>=3.0.0"] +dev = [ + "pytest==9.0.2", + "watchdog==6.0.0", + "agent-framework-orchestrations==1.0.0b260304", +] +all = [ + "pytest==9.0.2", + "watchdog==6.0.0", +] [project.scripts] devui = "agent_framework_devui:main" @@ -94,7 +100,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_devui" -test = "pytest -m \"not integration\" --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/durabletask/AGENTS.md b/python/packages/durabletask/AGENTS.md index 6e185bcd98..e0b1be1d19 100644 --- a/python/packages/durabletask/AGENTS.md +++ b/python/packages/durabletask/AGENTS.md @@ -29,18 +29,16 @@ Durable execution support for long-running agent workflows using Azure Durable F ## Usage ```python -from durabletask.client import TaskHubGrpcClient -from durabletask.worker import TaskHubGrpcWorker -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker +from durabletask.client import TaskHubGrpcClient +from durabletask.worker import TaskHubGrpcWorker # Client side dt_client = TaskHubGrpcClient(host_address="localhost:4001") agent_client = DurableAIAgentClient(dt_client) -agent = agent_client.get_agent("assistant") -response = agent.run("Hello, how are you?") -print(response.text) +durable_agent = agent_client.get_agent("assistant") # Worker side dt_worker = TaskHubGrpcWorker(host_address="localhost:4001") @@ -48,10 +46,8 @@ agent_worker = DurableAIAgentWorker(dt_worker) # Create a chat client for the agent chat_client = AzureOpenAIChatClient() -my_agent = ChatAgent(chat_client=chat_client, name="assistant") +my_agent = Agent(client=chat_client, name="assistant") agent_worker.add_agent(my_agent) - -dt_worker.start() ``` ## Import Path diff --git a/python/packages/durabletask/README.md b/python/packages/durabletask/README.md index aa67c9b3da..5447d19cea 100644 --- a/python/packages/durabletask/README.md +++ b/python/packages/durabletask/README.md @@ -15,17 +15,18 @@ The durable task integration lets you host Microsoft Agent Framework agents usin ### Basic Usage Example ```python +from agent_framework import Agent +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_durabletask import DurableAIAgentWorker from durabletask.worker import TaskHubGrpcWorker -from agent_framework.azure import DurableAIAgentWorker # Create the worker -with TaskHubGrpcWorker(...) as worker: +worker = TaskHubGrpcWorker(host_address="localhost:4001") +agent_worker = DurableAIAgentWorker(worker) - # Register the agent worker wrapper - agent_worker = DurableAIAgentWorker(worker) - - # Register the agent - agent_worker.add_agent(my_agent) +chat_client = AzureOpenAIChatClient() +my_agent = Agent(client=chat_client, name="assistant") +agent_worker.add_agent(my_agent) ``` For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory. diff --git a/python/packages/durabletask/agent_framework_durabletask/_worker.py b/python/packages/durabletask/agent_framework_durabletask/_worker.py index 96aa058d6f..e9670fd3cf 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_worker.py +++ b/python/packages/durabletask/agent_framework_durabletask/_worker.py @@ -29,9 +29,10 @@ class DurableAIAgentWorker: Example: ```python - from durabletask import TaskHubGrpcWorker + from durabletask.worker import TaskHubGrpcWorker from agent_framework import Agent - from agent_framework.azure import DurableAIAgentWorker + from agent_framework.azure import AzureOpenAIChatClient + from agent_framework_durabletask import DurableAIAgentWorker # Create the underlying worker worker = TaskHubGrpcWorker(host_address="localhost:4001") @@ -40,6 +41,7 @@ class DurableAIAgentWorker: agent_worker = DurableAIAgentWorker(worker) # Register agents + client = AzureOpenAIChatClient() my_agent = Agent(client=client, name="assistant") agent_worker.add_agent(my_agent) diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index 5d773bac60..44f87918d1 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -23,14 +23,14 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "durabletask>=1.3.0", - "durabletask-azuremanaged>=1.3.0", - "python-dateutil>=2.8.0", + "durabletask>=1.3.0,<2", + "durabletask-azuremanaged>=1.3.0,<2", + "python-dateutil>=2.8.0,<3", ] [dependency-groups] dev = [ - "types-python-dateutil>=2.9.0", + "types-python-dateutil==2.9.0.20260305", ] [tool.uv] @@ -99,7 +99,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask" -test = "pytest -m \"not integration\" --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 444ffc1278..6e21997c97 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "foundry-local-sdk>=0.5.1,<1", + "foundry-local-sdk>=0.5.1,<0.5.2", ] [tool.uv] @@ -86,7 +86,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_local" -test = "pytest -m \"not integration\" --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index f8340b1bce..4f71c9d3b5 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -25,20 +25,27 @@ from agent_framework._settings import load_settings from agent_framework._tools import FunctionTool, ToolTypes from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import AgentException -from copilot import CopilotClient, CopilotSession -from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType -from copilot.types import ( - CopilotClientOptions, - MCPServerConfig, - MessageOptions, - PermissionRequestResult, - ResumeSessionConfig, - SessionConfig, - SystemMessageConfig, - ToolInvocation, - ToolResult, -) -from copilot.types import Tool as CopilotTool + +try: + from copilot import CopilotClient, CopilotSession + from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType + from copilot.types import ( + CopilotClientOptions, + MCPServerConfig, + MessageOptions, + PermissionRequestResult, + ResumeSessionConfig, + SessionConfig, + SystemMessageConfig, + ToolInvocation, + ToolResult, + ) + from copilot.types import Tool as CopilotTool +except ImportError as _copilot_import_error: + raise ImportError( + "GitHubCopilotAgent requires the 'github-copilot-sdk' package, which is only available on Python 3.11+. " + "Please use Python 3.11 or later." + ) from _copilot_import_error if sys.version_info >= (3, 13): from typing import TypeVar diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index d6348ec446..abf4a5680d 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -3,7 +3,7 @@ name = "agent-framework-github-copilot" description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.10" version = "1.0.0b260311" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" @@ -15,6 +15,7 @@ classifiers = [ "Development Status :: 4 - Beta", "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", @@ -23,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "github-copilot-sdk>=0.1.32", + "github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'", ] [tool.uv] @@ -85,9 +86,16 @@ executor.type = "uv" include = "../../shared_tasks.toml" [tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_github_copilot" test = "pytest -m \"not integration\" --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests" +[tool.poe.tasks.pyright] +shell = "python -c \"import sys; exit(0 if sys.version_info < (3,11) else 1)\" || pyright" +interpreter = "posix" + +[tool.poe.tasks.mypy] +shell = "python -c \"import sys; exit(0 if sys.version_info < (3,11) else 1)\" || mypy --config-file $POE_ROOT/pyproject.toml agent_framework_github_copilot" +interpreter = "posix" + [build-system] requires = ["flit-core >= 3.11,<4.0"] build-backend = "flit_core.buildapi" diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index ed8c089fa3..dd2c259b7d 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: E402 + import unittest.mock from datetime import datetime, timezone from typing import Any @@ -7,6 +9,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest + +copilot = pytest.importorskip("copilot") + from agent_framework import ( AgentResponse, AgentResponseUpdate, diff --git a/python/packages/lab/README.md b/python/packages/lab/README.md index b6809a817c..a46893c2d1 100644 --- a/python/packages/lab/README.md +++ b/python/packages/lab/README.md @@ -62,6 +62,27 @@ For example, to use the GAIA module: from agent_framework.lab.gaia import GAIA ``` +## Running Tests Locally + +For machine-safe local runs, prefer package-scoped commands first: + +```bash +uv run --directory packages/lab poe test +uv run --directory packages/lab pytest -q -m "not integration" +``` + +When you need to run package tasks from the repository root, use sequential mode to avoid launching all package tests in parallel: + +```bash +uv run poe test --seq +``` + +Lightning observability tests intentionally exercise heavier tracing paths and are marked as `resource_intensive`: + +```bash +uv run --directory packages/lab pytest lightning/tests/test_lightning.py -m "resource_intensive" -q +``` + ## Should I consume Lab Modules? If you are looking for stable and production-ready features, you should not use lab modules. Stick to the core framework. diff --git a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py index cba407ded3..07b1945882 100644 --- a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py +++ b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py @@ -10,10 +10,11 @@ import re import string import tempfile import time -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime +from functools import lru_cache from pathlib import Path -from typing import Any, cast +from typing import Any, Protocol, cast from opentelemetry.trace import NoOpTracer, SpanKind, get_tracer from tqdm import tqdm @@ -23,6 +24,33 @@ from ._types import Evaluation, Evaluator, Prediction, Task, TaskResult, TaskRun __all__ = ["GAIA", "GAIATelemetryConfig", "gaia_scorer"] +class _OrjsonModule(Protocol): + def dumps(self, obj: object, /, default: Callable[[Any], object] | None = None) -> bytes: ... + + def loads(self, obj: str | bytes | bytearray, /) -> object: ... + + +@lru_cache(maxsize=1) +def _get_orjson() -> _OrjsonModule | None: + try: + import orjson as runtime_orjson # pyright: ignore[reportMissingImports] + except ImportError: + return None + return cast(_OrjsonModule, runtime_orjson) + + +def _dump_json_line(value: object) -> str: + if (runtime_orjson := _get_orjson()) is not None: + return runtime_orjson.dumps(value, default=str).decode("utf-8") + return json.dumps(value, default=str) + + +def _load_json_value(value: str | bytes) -> object: + if (runtime_orjson := _get_orjson()) is not None: + return runtime_orjson.loads(value) + return json.loads(value) + + class GAIATelemetryConfig: """Configuration for GAIA telemetry and tracing.""" @@ -226,13 +254,7 @@ def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]: for line in f: if not line.strip(): continue - parsed: object - try: - import orjson - - parsed = orjson.loads(line) - except Exception: - parsed = json.loads(line) + parsed = _load_json_value(line) record = _coerce_record(parsed) if record is not None: @@ -620,12 +642,7 @@ class GAIA: "prediction_metadata": result.prediction.metadata, "evaluation_details": result.evaluation.details, } - try: - import orjson - - f.write(orjson.dumps(record, default=str).decode("utf-8") + "\n") - except ImportError: - f.write(json.dumps(record, default=str) + "\n") + f.write(_dump_json_line(record) + "\n") def viewer_main() -> None: @@ -646,13 +663,7 @@ def viewer_main() -> None: with open(args.results_file, encoding="utf-8") as f: for line in f: if line.strip(): - try: - import orjson - - parsed: object = orjson.loads(line) - except ImportError: - parsed = json.loads(line) - + parsed = _load_json_value(line) record = _coerce_record(parsed) if record is not None: results.append(record) diff --git a/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py b/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py index 7017062b32..9526498cc2 100644 --- a/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py +++ b/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py @@ -2,10 +2,14 @@ """RL Module for Microsoft Agent Framework.""" +from __future__ import annotations + import importlib.metadata from agent_framework.observability import enable_instrumentation -from agentlightning import AgentOpsTracer # type: ignore +from agentlightning.tracer import ( + AgentOpsTracer, # pyright: ignore[reportMissingImports] # type: ignore[import-not-found] +) try: __version__ = importlib.metadata.version(__name__) @@ -23,11 +27,11 @@ class AgentFrameworkTracer(AgentOpsTracer): # type: ignore def init(self) -> None: """Initialize the agent-framework-lab-lightning for training.""" enable_instrumentation() - super().init() + super().init() # pyright: ignore[reportUnknownMemberType] def teardown(self) -> None: """Teardown the agent-framework-lab-lightning for training.""" - super().teardown() + super().teardown() # pyright: ignore[reportUnknownMemberType] __all__: list[str] = ["AgentFrameworkTracer"] diff --git a/python/packages/lab/lightning/tests/test_lightning.py b/python/packages/lab/lightning/tests/test_lightning.py index 76e6b98506..d4f6d20adf 100644 --- a/python/packages/lab/lightning/tests/test_lightning.py +++ b/python/packages/lab/lightning/tests/test_lightning.py @@ -7,12 +7,8 @@ from unittest.mock import AsyncMock, patch import pytest -agentlightning = pytest.importorskip("agentlightning") - from agent_framework import AgentExecutor, AgentResponse, Agent, WorkflowBuilder, Workflow -from agent_framework_lab_lightning import AgentFrameworkTracer from agent_framework.openai import OpenAIChatClient -from agentlightning import TracerTraceToTriplet from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice @@ -118,6 +114,7 @@ async def test_openai_workflow_two_agents(workflow_two_agents: Workflow): ) +@pytest.mark.resource_intensive async def test_observability(workflow_two_agents: Workflow): r"""Expected trace tree: @@ -129,6 +126,10 @@ async def test_observability(workflow_two_agents: Workflow): | | [chat gpt-4o] [chat gpt-4o] """ + pytest.importorskip("agentlightning") + from agent_framework_lab_lightning import AgentFrameworkTracer + from agentlightning.adapter import TracerTraceToTriplet + tracer = AgentFrameworkTracer() try: tracer.init() diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index d474a3bfcb..170b1d4b78 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -32,8 +32,8 @@ gaia = [ "opentelemetry-api>=1.39.0", "tqdm>=4.60.0", "huggingface-hub>=0.20.0", - "orjson>=3.8.0", - "pyarrow>=10.0.0", # For reading parquet files + "orjson>=3.10.7,<4", + "pyarrow>=18.0.0", # For reading parquet files ] # Lightning RL training module dependencies @@ -56,19 +56,19 @@ math = [ [dependency-groups] dev = [ - "uv", - "ruff>=0.11.8", - "pytest>=8.4.1", - "mypy>=1.16.1", - "pyright>=1.1.402", + "uv==0.10.9", + "ruff==0.15.5", + "pytest==9.0.2", + "mypy==1.19.1", + "pyright==1.1.408", #tasks - "poethepoet>=0.36.0", - "rich", - "tomli", - "tomli-w", + "poethepoet==0.42.1", + "rich==13.7.1", + "tomli==2.4.0", + "tomli-w==1.2.0", # tau2 from source (not available on PyPI) "tau2@ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619", - "prek>=0.3.2", + "prek==0.3.4", ] [project.scripts] @@ -144,7 +144,6 @@ targets = ["agent_framework_lab_gaia", "agent_framework_lab_lightning", "agent_f exclude_dirs = ["gaia/tests", "lightning/tests", "tau2/tests"] [tool.poe] -executor.type = "uv" include = "../../shared_tasks.toml" [tool.poe.tasks] @@ -152,10 +151,10 @@ mypy-gaia = "mypy --config-file $POE_ROOT/pyproject.toml gaia/agent_framework_la mypy-lightning = "mypy --config-file $POE_ROOT/pyproject.toml lightning/agent_framework_lab_lightning" mypy-tau2 = "mypy --config-file $POE_ROOT/pyproject.toml tau2/agent_framework_lab_tau2" mypy = ["mypy-gaia", "mypy-lightning", "mypy-tau2"] -test = "pytest -m \"not integration\" --cov-report=term-missing:skip-covered --junitxml=test-results.xml" -test-gaia = "pytest -m \"not integration\" gaia/tests --cov=agent_framework_lab_gaia --cov-report=term-missing:skip-covered" -test-lightning = "pytest -m \"not integration\" lightning/tests --cov=agent_framework_lab_lightning --cov-report=term-missing:skip-covered" -test-tau2 = "pytest -m \"not integration\" tau2/tests --cov=agent_framework_lab_tau2 --cov-report=term-missing:skip-covered" +test = 'pytest -m "not integration and not resource_intensive" --cov-report=term-missing:skip-covered --junitxml=test-results.xml' +test-gaia = "pytest gaia/tests --cov=agent_framework_lab_gaia --cov-report=term-missing:skip-covered" +test-lightning = "pytest lightning/tests --cov=agent_framework_lab_lightning --cov-report=term-missing:skip-covered" +test-tau2 = "pytest tau2/tests --cov=agent_framework_lab_tau2 --cov-report=term-missing:skip-covered" build = "echo 'Skipping build'" publish = "echo 'Skipping publish'" @@ -167,4 +166,5 @@ asyncio_default_fixture_loop_scope = "function" markers = [ "unit: marks tests as unit tests", "integration: marks tests as integration tests", + "resource_intensive: marks tests that are expensive and excluded from default package test runs", ] diff --git a/python/packages/lab/tau2/tests/test_tau2_utils.py b/python/packages/lab/tau2/tests/test_tau2_utils.py index f463c13ec8..15957d5120 100644 --- a/python/packages/lab/tau2/tests/test_tau2_utils.py +++ b/python/packages/lab/tau2/tests/test_tau2_utils.py @@ -2,62 +2,39 @@ """Tests for tau2 utils module.""" -import urllib.request -from pathlib import Path - -import pytest from agent_framework import Content, FunctionTool, Message from agent_framework_lab_tau2._tau2_utils import ( convert_agent_framework_messages_to_tau2_messages, convert_tau2_tool_to_function_tool, ) +from pydantic import BaseModel from tau2.data_model.message import AssistantMessage, SystemMessage, ToolCall, ToolMessage, UserMessage -from tau2.domains.airline.data_model import FlightDB -from tau2.domains.airline.tools import AirlineTools -from tau2.environment.environment import Environment -@pytest.fixture(scope="session") -def tau2_airline_environment() -> Environment: - airline_db_remote_path = "https://raw.githubusercontent.com/sierra-research/tau2-bench/5ba9e3e56db57c5e4114bf7f901291f09b2c5619/data/tau2/domains/airline/db.json" - airline_policy_remote_path = "https://raw.githubusercontent.com/sierra-research/tau2-bench/5ba9e3e56db57c5e4114bf7f901291f09b2c5619/data/tau2/domains/airline/policy.md" - - # Create cache directory - cache_dir = Path(__file__).parent / "data" - cache_dir.mkdir(exist_ok=True) - - # Define cache file paths - db_cache_path = cache_dir / "airline_db.json" - policy_cache_path = cache_dir / "airline_policy.md" - - # Download files only if they don't exist in cache - if not db_cache_path.exists(): - urllib.request.urlretrieve(airline_db_remote_path, db_cache_path) - - if not policy_cache_path.exists(): - urllib.request.urlretrieve(airline_policy_remote_path, policy_cache_path) - - # Load data from cached files - db = FlightDB.load(str(db_cache_path)) - tools = AirlineTools(db) - with open(policy_cache_path) as fp: - policy = fp.read() - - yield Environment( - domain_name="airline", - policy=policy, - tools=tools, - ) +class _DummyToolInput(BaseModel): + param: str -def test_convert_tau2_tool_to_function_tool_basic(tau2_airline_environment): +class _DummyToolResult(BaseModel): + output: str + + +class _DummyTau2Tool: + def __init__(self, name: str, description: str) -> None: + self.name = name + self._description = description + self.params = _DummyToolInput + + def _get_description(self) -> str: + return self._description + + def __call__(self, **kwargs: str) -> _DummyToolResult: + return _DummyToolResult(output=kwargs["param"]) + + +def test_convert_tau2_tool_to_function_tool_basic(): """Test basic conversion from tau2 tool to FunctionTool.""" - # Get real tools from tau2 environment - tools = tau2_airline_environment.get_tools() - - # Use the first available tool for testing - assert len(tools) > 0, "No tools available in environment" - tau2_tool = tools[0] + tau2_tool = _DummyTau2Tool(name="lookup_booking", description="Lookup booking by id.") # Convert the tool tool = convert_tau2_tool_to_function_tool(tau2_tool) @@ -68,20 +45,25 @@ def test_convert_tau2_tool_to_function_tool_basic(tau2_airline_environment): assert tool.description == tau2_tool._get_description() assert tool.input_model == tau2_tool.params - # Test that the function is callable (we won't call it with real params to avoid side effects) + result = tool.func(param="ABC123") + assert isinstance(result, _DummyToolResult) + assert result.output == "ABC123" assert callable(tool.func) -def test_convert_tau2_tool_to_function_tool_multiple_tools(tau2_airline_environment): +def test_convert_tau2_tool_to_function_tool_multiple_tools(): """Test conversion with multiple tau2 tools.""" - # Get real tools from tau2 environment - tools = tau2_airline_environment.get_tools() + tools = [ + _DummyTau2Tool(name="lookup_booking", description="Lookup booking by id."), + _DummyTau2Tool(name="cancel_booking", description="Cancel an existing booking."), + _DummyTau2Tool(name="check_policy", description="Get policy details."), + ] # Convert multiple tools - function_tools = [convert_tau2_tool_to_function_tool(tool) for tool in tools[:3]] # Test first 3 tools + function_tools = [convert_tau2_tool_to_function_tool(tool) for tool in tools] # Verify all conversions - for tool, tau2_tool in zip(function_tools, tools[:3], strict=False): + for tool, tau2_tool in zip(function_tools, tools, strict=False): assert isinstance(tool, FunctionTool) assert tool.name == tau2_tool.name assert tool.description == tau2_tool._get_description() diff --git a/python/packages/mem0/AGENTS.md b/python/packages/mem0/AGENTS.md index 3a17e7b137..c1defa5ff8 100644 --- a/python/packages/mem0/AGENTS.md +++ b/python/packages/mem0/AGENTS.md @@ -4,23 +4,25 @@ Integration with Mem0 for agent memory management. ## Main Classes -- **`Mem0Provider`** - Context provider that integrates Mem0 memory into agents +- **`Mem0ContextProvider`** - Context provider that integrates Mem0 memory into agents ## Usage ```python -from agent_framework.mem0 import Mem0Provider +from agent_framework.mem0 import Mem0ContextProvider -provider = Mem0Provider(api_key="your-key") -agent = Agent(..., context_provider=provider) +provider = Mem0ContextProvider( + api_key="your-key", + user_id="user-id", +) ``` ## Import Path ```python -from agent_framework.mem0 import Mem0Provider +from agent_framework.mem0 import Mem0ContextProvider # or directly: -from agent_framework_mem0 import Mem0Provider +from agent_framework_mem0 import Mem0ContextProvider ``` ## Notes diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 21ed3a8222..ea697ca046 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "mem0ai>=1.0.0", + "mem0ai>=1.0.0,<2", ] [tool.uv] @@ -87,7 +87,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_mem0" -test = "pytest -m \"not integration\" --cov=agent_framework_mem0 --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_mem0 --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index f20b25039a..52259f297a 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "ollama >= 0.5.3", + "ollama>=0.5.3,<0.5.4", ] [tool.uv] @@ -90,7 +90,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ollama" -test = "pytest -m \"not integration\" --cov=agent_framework_ollama --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_ollama --cov-report=term-missing:skip-covered tests' [tool.uv.build-backend] module-name = "agent_framework_ollama" diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index b10872f2a8..bd7d1c8ac5 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -85,7 +85,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_orchestrations" -test = "pytest -m \"not integration\" --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests" +test = 'pytest -m "not integration" --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 43da365ba8..05f3f4b828 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -25,8 +25,8 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "azure-core>=1.30.0", - "httpx>=0.27.0", + "azure-core>=1.30.0,<2", + "httpx>=0.27.0,<0.29", ] [tool.uv] @@ -86,7 +86,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_purview" -test = "pytest -m \"not integration\" --cov=agent_framework_purview --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_purview --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.9,<4.0"] diff --git a/python/packages/redis/AGENTS.md b/python/packages/redis/AGENTS.md index 3b575e5029..49ddd0a70b 100644 --- a/python/packages/redis/AGENTS.md +++ b/python/packages/redis/AGENTS.md @@ -4,22 +4,22 @@ Redis-based storage for agent threads and context. ## Main Classes -- **`RedisChatMessageStore`** - Persistent message store using Redis -- **`RedisProvider`** - Context provider with Redis backing +- **`RedisHistoryProvider`** - Persistent chat history provider using Redis +- **`RedisContextProvider`** - Context provider with Redis-backed retrieval ## Usage ```python -from agent_framework.redis import RedisChatMessageStore +from agent_framework.redis import RedisContextProvider, RedisHistoryProvider -store = RedisChatMessageStore(redis_url="redis://localhost:6379") -agent = Agent(..., chat_message_store_factory=lambda: store) +context_provider = RedisContextProvider(redis_url="redis://localhost:6379") +history_provider = RedisHistoryProvider(redis_url="redis://localhost:6379") ``` ## Import Path ```python -from agent_framework.redis import RedisChatMessageStore, RedisProvider +from agent_framework.redis import RedisContextProvider, RedisHistoryProvider # or directly: -from agent_framework_redis import RedisChatMessageStore +from agent_framework_redis import RedisContextProvider, RedisHistoryProvider ``` diff --git a/python/packages/redis/README.md b/python/packages/redis/README.md index 3517f460de..1732a42b17 100644 --- a/python/packages/redis/README.md +++ b/python/packages/redis/README.md @@ -10,15 +10,15 @@ pip install agent-framework-redis --pre ### Memory Context Provider -The `RedisProvider` enables persistent context & memory capabilities for your agents, allowing them to remember user preferences and conversation context across sessions and threads. +The `RedisContextProvider` enables persistent context and memory capabilities for your agents, allowing them to remember user preferences and conversation context across sessions and threads. #### Basic Usage Examples Review the set of [getting started examples](../../samples/02-agents/context_providers/redis/README.md) for using the Redis context provider. -### Redis Chat Message Store +### Redis History Provider -The `RedisChatMessageStore` provides persistent conversation storage using Redis Lists, enabling chat history to survive application restarts and support distributed applications. +The `RedisHistoryProvider` provides persistent conversation storage using Redis Lists, enabling chat history to survive application restarts and support distributed applications. #### Key Features diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index f35567ca6c..e1bb352696 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -24,9 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0rc4", - "redis>=6.4.0", - "redisvl>=0.8.2", - "numpy>=2.2.6" + "redis>=6.4.0,<7.2.1", + "redisvl>=0.11.0,<0.16", + "numpy>=2.2.6,<3" ] [tool.uv] @@ -89,7 +89,7 @@ include = "../../shared_tasks.toml" [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_redis" -test = "pytest -m \"not integration\" --cov=agent_framework_redis --cov-report=term-missing:skip-covered tests" +test = 'pytest -m "not integration" --cov=agent_framework_redis --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/pyproject.toml b/python/pyproject.toml index 82e113c811..5a413fef67 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -28,22 +28,22 @@ dependencies = [ [dependency-groups] dev = [ - "uv>=0.9,<1.0.0", - "flit>=3.12.0", - "ruff>=0.11.8", - "pytest>=8.4.1", - "pytest-asyncio>=1.0.0", - "pytest-cov>=6.2.1", - "pytest-xdist[psutil]>=3.8.0", - "pytest-timeout>=2.3.1", - "pytest-retry>=1", - "mypy>=1.16.1", - "pyright>=1.1.402", + "uv==0.10.9", + "flit==3.12.0", + "ruff==0.15.5", + "pytest==9.0.2", + "pytest-asyncio==1.3.0", + "pytest-cov==7.0.0", + "pytest-xdist[psutil]==3.8.0", + "pytest-timeout==2.4.0", + "pytest-retry==1.7.0", + "mypy==1.19.1", + "pyright==1.1.408", #tasks - "poethepoet>=0.36.0", - "rich", - "tomli", - "prek>=0.3.2", + "poethepoet==0.42.1", + "rich==13.7.1", + "tomli==2.4.0", + "prek==0.3.4", ] [tool.uv] @@ -54,18 +54,6 @@ environments = [ "sys_platform == 'linux'", "sys_platform == 'win32'" ] -override-dependencies = [ - # A conflict between the dependency of litellm[proxy] < 0.30.0, which is a dependency of agent-lightning - # and uvicorn >= 0.34.0, which is a dependency of tau2 - "uvicorn==0.38.0", - # Similar problem with websockets, which is a dependency conflict between litellm[proxy] and mcp - "websockets==15.0.1", - # grpcio 1.67.x has no Python 3.14 wheels; grpcio 1.76.0+ supports Python 3.14 - # litellm constrains grpcio<1.68.0 due to resource exhaustion bug (https://github.com/grpc/grpc/issues/38290) - # Use version-specific overrides to satisfy both constraints - "grpcio>=1.76.0; python_version >= '3.14'", - "grpcio>=1.62.3,<1.68.0; python_version < '3.14'", -] [tool.uv.workspace] members = [ "packages/*" ] @@ -149,8 +137,6 @@ ignore = [ "**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] "samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"] "*.ipynb" = ["CPY", "E501"] -# RUF070: Assignment before yield is intentional - context manager must exit before yielding -"**/agent_framework/_workflows/_workflow.py" = ["RUF070"] [tool.ruff.format] docstring-code-format = true @@ -213,7 +199,7 @@ executor.type = "uv" [tool.poe.tasks] markdown-code-lint = "uv run python scripts/check_md_code_blocks.py 'README.md' './packages/**/README.md' './samples/**/*.md' --exclude cookiecutter-agent-framework-lab --exclude tau2 --exclude 'packages/devui/frontend' --exclude context_providers/azure_ai_search" prek-install = "prek install --overwrite" -install = "uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit" +install = "uv sync --all-packages --all-extras --dev --frozen --prerelease=if-necessary-or-explicit" test = "python scripts/run_tasks_in_packages_if_exists.py test" fmt = "python scripts/run_tasks_in_packages_if_exists.py fmt" format.ref = "fmt" @@ -221,8 +207,9 @@ lint = "python scripts/run_tasks_in_packages_if_exists.py lint" samples-lint = "ruff check samples --fix --exclude samples/autogen-migration,samples/semantic-kernel-migration --ignore E501,ASYNC,B901,TD002" pyright = "python scripts/run_tasks_in_packages_if_exists.py pyright" mypy = "python scripts/run_tasks_in_packages_if_exists.py mypy" -samples-syntax = "pyright -p pyrightconfig.samples.json --warnings" typing = "python scripts/run_tasks_in_packages_if_exists.py mypy pyright" +samples-syntax.shell = "pyright -p $(python -c \"import sys; print('pyrightconfig.samples.py310.json' if sys.version_info < (3,11) else 'pyrightconfig.samples.json')\") --warnings" +samples-syntax.interpreter = "posix" # cleaning clean-dist-packages = "python scripts/run_tasks_in_packages_if_exists.py clean-dist" clean-dist-meta = "rm -rf dist" @@ -287,6 +274,58 @@ sequence = [ ] args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] +[tool.poe.tasks.upgrade-dev-dependency-pins] +cmd = "python -m scripts.dependencies.upgrade_dev_dependencies" + +[tool.poe.tasks.upgrade-lockfile] +cmd = "uv lock --upgrade" + +[tool.poe.tasks.upgrade-dev-dependencies] +sequence = [ + { ref = "upgrade-dev-dependency-pins" }, + { ref = "upgrade-lockfile" }, + { ref = "install" }, + { ref = "check" }, + { ref = "typing" }, + { ref = "test" }, +] + +[tool.poe.tasks.add-dependency-to-project] +cmd = "uv add --package ${project} ${dependency}" +args = [ + { name = "project", options = ["-p", "--project"] }, + { name = "dependency", options = ["-d", "--dependency"] }, +] + +[tool.poe.tasks.validate-dependency-bounds-test] +shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test --package \"$project\"" +args = [{ name = "project", default = "*", options = ["-p", "--project"] }] + +[tool.poe.tasks.validate-dependency-bounds-project] +shell = """ +command=(python -m scripts.dependencies.validate_dependency_bounds --mode "${mode}" --package "${project}") +if [ -n "${dependency}" ]; then + command+=(--dependencies "${dependency}") +fi +"${command[@]}" +""" +interpreter = "bash" +args = [ + { name = "mode", default = "both", options = ["-m", "--mode"] }, + { name = "project", default = "*", options = ["-p", "--project"] }, + { name = "dependency", default = "", options = ["-d", "--dependency"] }, +] + +[tool.poe.tasks.add-dependency-and-validate-bounds] +sequence = [ + { ref = "add-dependency-to-project --project ${project} --dependency ${dependency}" }, + { ref = "validate-dependency-bounds-project --mode both --project ${project} --dependency ${dependency}" }, +] +args = [ + { name = "project", options = ["-p", "--project"] }, + { name = "dependency", options = ["-d", "--dependency"] }, +] + [tool.poe.tasks.prek-pyright] cmd = "uv run python scripts/run_tasks_in_changed_packages.py pyright --files ${files}" args = [{ name = "files", default = ".", positional = true, multiple = true }] diff --git a/python/pyrightconfig.samples.py310.json b/python/pyrightconfig.samples.py310.json new file mode 100644 index 0000000000..694abcb914 --- /dev/null +++ b/python/pyrightconfig.samples.py310.json @@ -0,0 +1,17 @@ +{ + "include": ["samples"], + "exclude": [ + "**/autogen/**", + "**/autogen-migration/**", + "**/semantic-kernel-migration/**", + "**/demos/**", + "**/_to_delete/**", + "**/05-end-to-end/**", + "**/agent_with_foundry_tracing.py", + "**/azure_responses_client_with_foundry.py", + "**/github_copilot/**" + ], + "typeCheckingMode": "off", + "reportMissingImports": "error", + "reportAttributeAccessIssue": "error" +} diff --git a/python/scripts/__init__.py b/python/scripts/__init__.py new file mode 100644 index 0000000000..7994120871 --- /dev/null +++ b/python/scripts/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +"""Shared Python workspace scripts.""" diff --git a/python/scripts/dependencies/README.md b/python/scripts/dependencies/README.md new file mode 100644 index 0000000000..5ce410d766 --- /dev/null +++ b/python/scripts/dependencies/README.md @@ -0,0 +1,95 @@ +# Dependency Scripts + +This folder contains the Python workspace tooling for dependency maintenance: + +- validating runtime dependency lower and upper bounds +- refreshing exact dev dependency pins +- writing dependency validation reports for local runs and workflows + +Run the commands below from the `python/` directory. + +## Files in this folder + +- `validate_dependency_bounds.py` + - Main entrypoint for dependency-bound workflows. + - Supports `test`, `lower`, `upper`, and `both` modes. + - `test` runs workspace-wide smoke validation at the lower and upper ends of the currently allowed ranges. + - `lower`, `upper`, and `both` dispatch to the lower/upper optimizer implementations for one package. + +- `upgrade_dev_dependencies.py` + - Refreshes exact dev dependency pins across the root `pyproject.toml` and package `pyproject.toml` files. + - Reuses the same version-selection logic as the upper-bound tooling so direct dev-tooling refreshes and dependency-range expansion stay consistent. + +- `_dependency_bounds_lower_impl.py` + - Package-scoped lower-bound optimizer. + - Tries older dependency versions within the currently allowed line and keeps the oldest passing lower bound. + - Writes `dependency-lower-bound-results.json` in this folder by default. + +- `_dependency_bounds_upper_impl.py` + - Package-scoped upper-bound optimizer. + - Tries newer dependency versions within candidate lines and keeps the newest passing upper bound. + - Also contains shared parsing/rewrite helpers reused by `upgrade_dev_dependencies.py`. + - Writes `dependency-range-results.json` in this folder by default. + +- `_dependency_bounds_runtime.py` + - Shared helper used by the validators to build isolated `uv run` commands. + - Reattaches the repo-wide toolchain (`ruff`, `pyright`, `pytest`, `poethepoet`, and related helpers) inside temporary environments so package tasks behave the same way they do in the workspace. + + +## Common entrypoints + +### Poe tasks + +These are the normal user-facing entrypoints: + +```bash +uv run poe upgrade-dev-dependency-pins +uv run poe upgrade-dev-dependencies +uv run poe validate-dependency-bounds-test +uv run poe validate-dependency-bounds-test --project +uv run poe validate-dependency-bounds-project --mode both --project --dependency "" +``` + +- `upgrade-dev-dependency-pins` only refreshes exact dev pins in `pyproject.toml` files. +- `upgrade-dev-dependencies` refreshes dev pins (using task above), runs `uv lock --upgrade`, reinstalls from the frozen lockfile, then runs `check`, `typing`, and `test`. +- `validate-dependency-bounds-test` runs the repo-wide lower/upper smoke gate. +- `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--project` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs. + +### GitHub Actions workflows + +These workflows call the Poe tasks: + +- `.github/workflows/python-dependency-range-validation.yml` + - Trigger: `workflow_dispatch` + - Runs `uv run poe validate-dependency-bounds-project --mode upper --project "*"` + - Uploads `python/scripts/dependencies/dependency-range-results.json` + - Creates issues for failing candidate versions and opens/updates a PR for passing range updates + +- `.github/workflows/python-dev-dependency-upgrade.yml` + - Trigger: `workflow_dispatch` + - Runs `uv run poe upgrade-dev-dependencies` + - Commits any resulting `pyproject.toml` / `uv.lock` changes and opens/updates a PR + +### Direct module execution + +These are useful for debugging or targeted manual runs: + +```bash +python -m scripts.dependencies.upgrade_dev_dependencies --dry-run --version-source lock +python -m scripts.dependencies.validate_dependency_bounds --mode test --package packages/core --dry-run +python -m scripts.dependencies.validate_dependency_bounds --mode both --package packages/core --dependencies openai --dry-run +python -m scripts.dependencies._dependency_bounds_lower_impl --packages packages/core --dependencies openai --dry-run +python -m scripts.dependencies._dependency_bounds_upper_impl --packages packages/core --dependencies openai --dry-run +``` + +Use the direct lower/upper implementation modules mainly for debugging or development of the optimizers themselves. For normal usage, prefer the Poe tasks or `validate_dependency_bounds.py`. + +## Generated report files + +The validators write JSON reports into this folder: + +- `dependency-bounds-test-results.json` +- `dependency-lower-bound-results.json` +- `dependency-range-results.json` + +These report files are ignored by git. diff --git a/python/scripts/dependencies/__init__.py b/python/scripts/dependencies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/scripts/dependencies/_dependency_bounds_lower_impl.py b/python/scripts/dependencies/_dependency_bounds_lower_impl.py new file mode 100644 index 0000000000..ad259bd6e6 --- /dev/null +++ b/python/scripts/dependencies/_dependency_bounds_lower_impl.py @@ -0,0 +1,1097 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: INP001, S404, S603 + +"""Lower dependency bounds, validate, and persist the oldest passing set.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import os +import re +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from urllib import error as urllib_error +from urllib import request as urllib_request + +import tomli +from scripts.dependencies._dependency_bounds_runtime import ( + extend_command_with_runtime_tools, + extend_command_with_task, +) +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import InvalidVersion, Version +from rich import print +from scripts.task_runner import discover_projects, extract_poe_tasks + +CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") +REQ_PATTERN = r"^\s*([A-Za-z0-9_.-]+(?:\[[^\]]+\])?)\s*(.*?)\s*$" +SECTION_HEADER_PATTERN = re.compile(r"^\s*\[([^\]]+)\]\s*$") +INLINE_ARRAY_ASSIGNMENT_PATTERN = re.compile( + r"^(?P\s*)(?P[A-Za-z0-9_.-]+)\s*=\s*\[(?P.*)\](?P\s*(?:#.*)?)$" +) +QUOTED_STRING_PATTERN = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') + + +@dataclass +class RequirementEntry: + """A parsed requirement entry from pyproject dependencies.""" + + raw: str + name: str + name_extras: str + marker: str | None + spec_parts: list[str] + lower_version: Version | None + lower_index: int | None + upper_index: int | None + upper_version: Version | None + exact_index: int | None = None + exact_version: Version | None = None + + def with_lower(self, lower: Version) -> str: + """Return a new requirement with the given inclusive lower bound.""" + updated_parts = list(self.spec_parts) + if self.exact_index is not None: + raise ValueError(f"Exact pin cannot be lowered in-place: {self.raw}") + if self.lower_index is not None: + updated_parts[self.lower_index] = f">={lower}" + else: + updated_parts.insert(0, f">={lower}") + spec = ",".join(updated_parts) + requirement = f"{self.name_extras}{spec}" + if self.marker: + requirement += f"; {self.marker}" + return requirement + + +@dataclass +class DependencyTarget: + """A dependency to optimize within one package.""" + + name: str + entries: list[RequirementEntry] + lower_version: Version | None + upper_version: Version + allow_prerelease_candidates: bool + + @property + def original_requirements(self) -> list[str]: + """Return original requirement strings for this dependency group.""" + return [entry.raw for entry in self.entries] + + +@dataclass +class DependencyAttempt: + """A single lower-bound trial for one dependency.""" + + trial_lower: str + status: str + error: str | None = None + + +@dataclass +class DependencyOutcome: + """Final outcome for one dependency optimization.""" + + name: str + changed: bool + original_requirements: list[str] + final_requirements: list[str] + candidate_versions: list[str] + attempted_versions: list[str] + attempts: list[DependencyAttempt] + skipped_reason: str | None = None + + +@dataclass +class PackagePlan: + """Execution plan for a package.""" + + project_path: Path + package_name: str + pyproject_path: Path + internal_editables: list[Path] + include_dev_group: bool + include_dev_extra: bool + optional_extras: list[str] + + +@dataclass +class PackageOutcome: + """Execution outcome for a package.""" + + project_path: str + package_name: str + tasks: list[str] + changed: bool + dependencies: list[DependencyOutcome] + replacements: dict[str, str] + skipped: list[str] + error: str | None = None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _truncate_error(stdout: str, stderr: str, *, max_chars: int = 2000) -> str: + combined = "\n".join(part for part in [stderr.strip(), stdout.strip()] if part) + if len(combined) <= max_chars: + return combined + return f"...\n{combined[-max_chars:]}" + + +def _parse_requirement(requirement: str) -> RequirementEntry | None: + match = re.match(REQ_PATTERN, requirement) + if not match: + return None + name_extras = match.group(1) + rest = match.group(2).strip() + marker = None + if ";" in rest: + spec_part, marker_part = rest.split(";", 1) + spec = spec_part.strip() + marker = marker_part.strip() + else: + spec = rest + if not spec: + return None + + spec_parts = [part.strip() for part in spec.split(",") if part.strip()] + if not spec_parts: + return None + + lower_version: Version | None = None + lower_index: int | None = None + upper_version: Version | None = None + upper_index: int | None = None + exact_version: Version | None = None + exact_index: int | None = None + + for index, part in enumerate(spec_parts): + if part.startswith((">=", ">")): + raw_version = part[2:].strip() if part.startswith(">=") else part[1:].strip() + try: + parsed = Version(raw_version) + except InvalidVersion: + continue + if lower_version is None or parsed > lower_version: + lower_version = parsed + lower_index = index + elif part.startswith(("==", "===")): + raw_version = part[3:].strip() if part.startswith("===") else part[2:].strip() + try: + parsed = Version(raw_version) + except InvalidVersion: + continue + exact_version = parsed + exact_index = index + if lower_version is None or parsed > lower_version: + lower_version = parsed + lower_index = None + if part.startswith(("<", "<=")): + raw_version = part[2:].strip() if part.startswith("<=") else part[1:].strip() + try: + parsed = Version(raw_version) + except InvalidVersion: + continue + if upper_version is None or parsed < upper_version: + upper_version = parsed + upper_index = index + + if upper_version is None and exact_version is None: + return None + name = name_extras.split("[", 1)[0].lower() + return RequirementEntry( + raw=requirement, + name=name, + name_extras=name_extras, + marker=marker, + spec_parts=spec_parts, + lower_version=lower_version, + lower_index=lower_index, + upper_index=upper_index, + upper_version=upper_version, + exact_index=exact_index, + exact_version=exact_version, + ) + + +def _is_dependency_array_assignment(section: str, key: str) -> bool: + if section == "project": + return key == "dependencies" + return section in {"project.optional-dependencies", "dependency-groups"} + + +def _extract_inline_array_items(array_body: str) -> list[str] | None: + items = [match.group(0) for match in QUOTED_STRING_PATTERN.finditer(array_body)] + remainder = QUOTED_STRING_PATTERN.sub("", array_body) + if remainder.replace(",", "").strip(): + return None + return items + + +def _format_dependency_arrays_multiline(path: Path) -> None: + original_text = path.read_text() + lines = original_text.splitlines() + current_section = "" + updated_lines: list[str] = [] + changed = False + + for line in lines: + section_match = SECTION_HEADER_PATTERN.match(line) + if section_match: + current_section = section_match.group(1).strip() + updated_lines.append(line) + continue + + assignment_match = INLINE_ARRAY_ASSIGNMENT_PATTERN.match(line) + if assignment_match is None: + updated_lines.append(line) + continue + + indent = assignment_match.group("indent") + key = assignment_match.group("key") + body = assignment_match.group("body") + suffix = (assignment_match.group("suffix") or "").rstrip() + if not _is_dependency_array_assignment(current_section, key): + updated_lines.append(line) + continue + + items = _extract_inline_array_items(body) + if items is None or len(items) == 0: + updated_lines.append(line) + continue + + changed = True + updated_lines.append(f"{indent}{key} = [") + updated_lines.extend(f"{indent} {item}," for item in items) + closing_line = f"{indent}]" + if suffix: + closing_line = f"{closing_line}{suffix}" + updated_lines.append(closing_line) + + if not changed: + return + + updated_text = "\n".join(updated_lines) + if original_text.endswith("\n"): + updated_text += "\n" + path.write_text(updated_text) + + +def _replace_requirements(path: Path, replacements: list[tuple[str, str]]) -> None: + text = path.read_text() + updated_text = text + for old, new in replacements: + replaced = False + old_double = f'"{old}"' + old_single = f"'{old}'" + new_double = f'"{new}"' + new_single = f"'{new}'" + if old_double in updated_text: + updated_text = updated_text.replace(old_double, new_double) + replaced = True + if old_single in updated_text: + updated_text = updated_text.replace(old_single, new_single) + replaced = True + if not replaced: + raise ValueError(f"Could not find dependency string in {path}: {old}") + if updated_text != text: + path.write_text(updated_text) + + +def _load_lock_versions(workspace_root: Path) -> dict[str, list[Version]]: + lock_file = workspace_root / "uv.lock" + if not lock_file.exists(): + return {} + with lock_file.open("rb") as f: + lock_data = tomli.load(f) + versions_by_name: dict[str, set[Version]] = {} + for package_data in lock_data.get("package", []): + package_name = str(package_data.get("name", "")).lower() + package_version = package_data.get("version") + if not package_name or not package_version: + continue + try: + parsed = Version(str(package_version)) + except InvalidVersion: + continue + versions_by_name.setdefault(package_name, set()).add(parsed) + return {name: sorted(values) for name, values in versions_by_name.items()} + + +class VersionCatalog: + """Cache and fetch available dependency versions.""" + + def __init__(self, lock_versions: dict[str, list[Version]], source: str) -> None: + """Initialize the catalog with lock-based fallback and fetch source.""" + self._lock_versions = lock_versions + self._source = source + self._cache: dict[str, list[Version]] = {} + self._lock = threading.Lock() + + def get(self, package_name: str) -> list[Version]: + """Return cached or fetched versions for a package name.""" + with self._lock: + cached = self._cache.get(package_name) + if cached is not None: + return cached + versions = self._fetch(package_name) + with self._lock: + self._cache[package_name] = versions + return versions + + def _fetch(self, package_name: str) -> list[Version]: + if self._source == "lock": + return self._lock_versions.get(package_name, []) + + try: + url = f"https://pypi.org/pypi/{package_name}/json" + with urllib_request.urlopen(url, timeout=20) as response: + payload = json.load(response) + except (urllib_error.URLError, TimeoutError, json.JSONDecodeError): + return self._lock_versions.get(package_name, []) + + versions: set[Version] = set() + for raw_version, files in payload.get("releases", {}).items(): + if not files: + continue + non_yanked = any(not bool(file_info.get("yanked", False)) for file_info in files) + if not non_yanked: + continue + try: + versions.add(Version(raw_version)) + except InvalidVersion: + continue + if versions: + return sorted(versions) + return self._lock_versions.get(package_name, []) + + +def _load_package_name(pyproject_file: Path) -> str: + with pyproject_file.open("rb") as f: + data = tomli.load(f) + return str(data["project"]["name"]) + + +def _extract_requirement_name(requirement: str) -> str | None: + try: + return Requirement(requirement).name.lower() + except InvalidRequirement: + return None + + +def _select_validation_tasks(available_tasks: set[str]) -> list[str]: + check_task = next((task for task in CHECK_TASK_PRIORITY if task in available_tasks), None) + tasks: list[str] = [] + if check_task: + tasks.append(check_task) + if "test" in available_tasks and "test" not in tasks: + tasks.append("test") + return tasks + + +def _build_workspace_package_map(workspace_root: Path) -> dict[str, Path]: + package_map: dict[str, Path] = {} + for pyproject_file in sorted((workspace_root / "packages").glob("*/pyproject.toml")): + with pyproject_file.open("rb") as f: + data = tomli.load(f) + package_name = str(data.get("project", {}).get("name", "")).strip() + if package_name: + package_map[package_name] = pyproject_file.parent + return package_map + + +def _build_internal_graph(workspace_root: Path, package_map: dict[str, Path]) -> dict[str, set[str]]: + graph: dict[str, set[str]] = {} + for package_name, package_path in package_map.items(): + pyproject_file = package_path / "pyproject.toml" + with pyproject_file.open("rb") as f: + data = tomli.load(f) + project = data.get("project", {}) or {} + dependencies: list[str] = list(project.get("dependencies", []) or []) + for values in (project.get("optional-dependencies", {}) or {}).values(): + dependencies.extend([value for value in (values or []) if isinstance(value, str)]) + for values in (data.get("dependency-groups", {}) or {}).values(): + dependencies.extend([value for value in (values or []) if isinstance(value, str)]) + internal = set() + for dependency in dependencies: + dependency_name = _extract_requirement_name(dependency) + if dependency_name is None: + continue + if dependency_name.startswith("agent-framework"): + for candidate_name in package_map: + if candidate_name.lower() == dependency_name: + internal.add(candidate_name) + break + graph[package_name] = internal + return graph + + +def _resolve_internal_editables( + package_name: str, package_map: dict[str, Path], graph: dict[str, set[str]] +) -> list[Path]: + visited: set[str] = set() + stack = [package_name] + results: set[Path] = set() + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + for dependency_name in graph.get(current, set()): + dependency_path = package_map.get(dependency_name) + if dependency_path and dependency_name != package_name: + results.add(dependency_path.resolve()) + stack.append(dependency_name) + return sorted(results) + + +def _collect_targets( + pyproject_file: Path, + *, + dependency_filters: set[str] | None, +) -> tuple[list[DependencyTarget], list[str]]: + with pyproject_file.open("rb") as f: + data = tomli.load(f) + project = data.get("project", {}) + dependencies: list[str] = list(project.get("dependencies", []) or []) + # Lower-bound validation also covers optional extras because those dependency ranges are part + # of the supported install surface just as much as base runtime dependencies are. + for values in (project.get("optional-dependencies", {}) or {}).values(): + dependencies.extend(values or []) + + grouped: dict[str, list[RequirementEntry]] = {} + skipped: list[str] = [] + + for dependency in dependencies: + parsed = _parse_requirement(dependency) + if not parsed: + continue + if parsed.name.startswith("agent-framework"): + continue + if dependency_filters and parsed.name not in dependency_filters: + continue + grouped.setdefault(parsed.name, []).append(parsed) + + targets: list[DependencyTarget] = [] + for dependency_name, entries in sorted(grouped.items()): + if not entries: + continue + # A dependency can be repeated across base + extra requirements. Only optimize it when the + # whole package agrees on one bounded shape so we never "fix" one occurrence but not another. + allow_prerelease_candidates = any( + ( + (entry.lower_version is not None and entry.lower_version.is_prerelease) + or (entry.upper_version is not None and entry.upper_version.is_prerelease) + or (entry.exact_version is not None and entry.exact_version.is_prerelease) + ) + for entry in entries + ) + upper_entries = [entry for entry in entries if entry.upper_version is not None] + exact_entries = [entry for entry in entries if entry.exact_version is not None] + + if upper_entries: + if len(upper_entries) != len(entries): + skipped.append(f"{dependency_name}: mixed bounded and unbounded/exact requirements in package") + continue + first_upper = upper_entries[0].upper_version + if first_upper is None: + skipped.append(f"{dependency_name}: missing upper bound value") + continue + if any(entry.upper_version != first_upper for entry in upper_entries[1:]): + skipped.append(f"{dependency_name}: conflicting upper bounds in package") + continue + lower_versions = [entry.lower_version for entry in entries if entry.lower_version is not None] + if not lower_versions: + skipped.append(f"{dependency_name}: missing lower bound value") + continue + lower = max(lower_versions) + targets.append( + DependencyTarget( + name=dependency_name, + entries=entries, + lower_version=lower, + upper_version=first_upper, + allow_prerelease_candidates=allow_prerelease_candidates, + ) + ) + continue + + if exact_entries and len(exact_entries) == len(entries): + skipped.append(f"{dependency_name}: exact pins are skipped for lower-bound optimization") + continue + + skipped.append(f"{dependency_name}: no usable bounded range to optimize") + return targets, skipped + + +def _build_trial_lower_bounds( + versions: list[Version], + *, + lower: Version, + current_upper: Version, + allow_prerelease: bool, + max_candidates: int, +) -> list[Version]: + # Lower-bound probing stays inside the currently supported compatibility lane: + # stable tracks never cross a major boundary, and 0.x tracks may walk across + # multiple minor lines. The final bound is only rewritten after an exact-version + # probe passes via `uv run --with ==`. + candidates = [version for version in versions if version < lower and version < current_upper] + # `packaging` treats .dev/.a/.b/.rc as prereleases; only probe them when current spec already uses them. + if not allow_prerelease: + candidates = [version for version in candidates if not version.is_prerelease] + if lower.major >= 1: + major_floor = Version(f"{lower.major}.0.0") + candidates = [version for version in candidates if version.major == lower.major and version >= major_floor] + elif lower.major == 0: + candidates = [version for version in candidates if version.major == 0] + + candidates.sort() + if max_candidates > 0: + return candidates[:max_candidates] + return candidates + + +def _run_tasks( + project_dir: Path, + *, + workspace_root: Path, + tasks: list[str], + internal_editables: list[Path], + resolution: str, + dependency_pin: tuple[str, Version] | None, + include_dev_group: bool, + include_dev_extra: bool, + optional_extras: list[str], + timeout_seconds: int, +) -> tuple[bool, str | None]: + # Every probe runs inside a fresh isolated uv environment. Clearing VIRTUAL_ENV avoids + # leaking the caller's active environment into the subprocess and suppresses uv mismatch warnings. + env = dict(os.environ) + env["UV_PRERELEASE"] = "allow" + # Avoid letting nested uv commands target the caller's active environment; validation should + # stay inside uv's isolated throwaway environment instead of mutating `.venv`. + env.pop("VIRTUAL_ENV", None) + for task_name in tasks: + command = [ + "uv", + "--no-progress", + "--directory", + str(project_dir), + "run", + "--isolated", + "--resolution", + resolution, + "--prerelease", + "allow", + "--quiet", + ] + extend_command_with_runtime_tools(command, workspace_root) + if include_dev_group: + command.extend(["--group", "dev"]) + if include_dev_extra: + command.extend(["--extra", "dev"]) + for extra_name in optional_extras: + command.extend(["--extra", extra_name]) + for editable_path in internal_editables: + command.extend(["--with-editable", str(editable_path)]) + if dependency_pin is not None: + dependency_name, dependency_version = dependency_pin + command.extend(["--with", f"{dependency_name}=={dependency_version}"]) + extend_command_with_task(command, task_name) + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + env=env, + ) + except subprocess.TimeoutExpired: + return False, f"Timeout while running task '{task_name}'." + if result.returncode != 0: + return ( + False, + f"Task '{task_name}' failed.\n{_truncate_error(result.stdout, result.stderr)}", + ) + return True, None + + +def _optimize_dependency( + *, + temp_pyproject: Path, + dependency: DependencyTarget, + available_versions: list[Version], + tasks: list[str], + internal_editables: list[Path], + dry_run: bool, + max_candidates: int, + timeout_seconds: int, + package_label: str, + include_dev_group: bool, + include_dev_extra: bool, + optional_extras: list[str], +) -> DependencyOutcome: + if dependency.lower_version is None: + return DependencyOutcome( + name=dependency.name, + changed=False, + original_requirements=dependency.original_requirements, + final_requirements=dependency.original_requirements, + candidate_versions=[], + attempted_versions=[], + attempts=[], + skipped_reason="No lower bound available for optimization.", + ) + + candidates = _build_trial_lower_bounds( + available_versions, + lower=dependency.lower_version, + current_upper=dependency.upper_version, + allow_prerelease=dependency.allow_prerelease_candidates, + max_candidates=max_candidates, + ) + candidate_versions = [str(candidate) for candidate in candidates] + attempted_versions: list[str] = [] + attempts: list[DependencyAttempt] = [] + best_lower = dependency.lower_version + + # Establish a validated baseline before searching for lower acceptable bounds. + # Lower-bound discovery should mirror the repo smoke gate, so probe candidates + # under `lowest-direct` rather than `highest`. + baseline_version = dependency.lower_version + attempted_versions.append(str(baseline_version)) + print(f"[cyan]{package_label} :: {dependency.name} :: baseline current_lower [{baseline_version}] [/cyan]") + success, error = _run_tasks( + temp_pyproject.parent, + workspace_root=temp_pyproject.parent.parent.parent, + tasks=tasks, + internal_editables=internal_editables, + resolution="lowest-direct", + dependency_pin=(dependency.name, baseline_version), + include_dev_group=include_dev_group, + include_dev_extra=include_dev_extra, + optional_extras=optional_extras, + timeout_seconds=timeout_seconds, + ) + if not success: + attempts.append( + DependencyAttempt( + trial_lower=str(baseline_version), + status="failed", + error=error, + ) + ) + return DependencyOutcome( + name=dependency.name, + changed=False, + original_requirements=dependency.original_requirements, + final_requirements=dependency.original_requirements, + candidate_versions=candidate_versions, + attempted_versions=attempted_versions, + attempts=attempts, + skipped_reason="Baseline validation failed at current_lower.", + ) + + attempts.append( + DependencyAttempt( + trial_lower=str(baseline_version), + status="current_lower_passed", + ) + ) + + if not candidates: + return DependencyOutcome( + name=dependency.name, + changed=False, + original_requirements=dependency.original_requirements, + final_requirements=dependency.original_requirements, + candidate_versions=[], + attempted_versions=attempted_versions, + attempts=attempts, + skipped_reason="No lower candidate bounds found within allowed boundary.", + ) + + # Probe older bounds with a binary-search-style loop: keep successful tighter lowers, revert failures. + low = 0 + high = len(candidates) - 1 + while low <= high: + midpoint = (low + high) // 2 + candidate = candidates[midpoint] + attempted_versions.append(str(candidate)) + + print(f"[cyan]{package_label} :: {dependency.name} -> >={candidate}[/cyan]") + success, error = _run_tasks( + temp_pyproject.parent, + workspace_root=temp_pyproject.parent.parent.parent, + tasks=tasks, + internal_editables=internal_editables, + resolution="lowest-direct", + dependency_pin=(dependency.name, candidate), + include_dev_group=include_dev_group, + include_dev_extra=include_dev_extra, + optional_extras=optional_extras, + timeout_seconds=timeout_seconds, + ) + if success: + attempts.append(DependencyAttempt(trial_lower=str(candidate), status="passed")) + best_lower = candidate + high = midpoint - 1 + continue + + attempts.append(DependencyAttempt(trial_lower=str(candidate), status="failed", error=error)) + low = midpoint + 1 + + final_requirements = ( + [entry.with_lower(best_lower) for entry in dependency.entries] + if best_lower != dependency.lower_version + else dependency.original_requirements + ) + changed = final_requirements != dependency.original_requirements + return DependencyOutcome( + name=dependency.name, + changed=changed, + original_requirements=dependency.original_requirements, + final_requirements=final_requirements, + candidate_versions=candidate_versions, + attempted_versions=attempted_versions, + attempts=attempts, + ) + + +def _process_package( + plan: PackagePlan, + *, + catalog: VersionCatalog, + dependency_filters: set[str] | None, + dry_run: bool, + max_candidates: int, + timeout_seconds: int, +) -> PackageOutcome: + pyproject_file = plan.pyproject_path + source_workspace_root = pyproject_file.parent.parent.parent.resolve() + available_tasks = extract_poe_tasks(pyproject_file) + tasks = _select_validation_tasks(available_tasks) + if not tasks: + return PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=[], + changed=False, + dependencies=[], + replacements={}, + skipped=["No check/test task combination found."], + ) + + # Build the per-package optimization target set from eligible bounded dependency specifications. + targets, skipped = _collect_targets(pyproject_file, dependency_filters=dependency_filters) + if not targets: + return PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=tasks, + changed=False, + dependencies=[], + replacements={}, + skipped=[*skipped, "No eligible dependencies with lower+upper bounds."], + ) + + with tempfile.TemporaryDirectory(prefix=f"dep-lower-{plan.project_path.name}-") as temp_dir: + temp_root = Path(temp_dir) + temp_workspace_root = temp_root / source_workspace_root.name + # Copy the whole workspace so uv workspace sources and editable internal packages resolve + # the same way they do in the real checkout while keeping trial rewrites fully isolated. + shutil.copytree( + source_workspace_root, + temp_workspace_root, + ignore=shutil.ignore_patterns( + ".git", + ".venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "node_modules", + "dist", + ), + ) + + temp_packages_dir = temp_workspace_root / "packages" + if temp_packages_dir.exists(): + for package_dir in temp_packages_dir.iterdir(): + if package_dir.is_dir() and not (package_dir / "pyproject.toml").exists(): + shutil.rmtree(package_dir) + + temp_project_dir = temp_workspace_root / plan.project_path + temp_pyproject = temp_project_dir / "pyproject.toml" + temp_internal_editables: list[Path] = [] + for editable in plan.internal_editables: + try: + relative_editable = editable.resolve().relative_to(source_workspace_root) + except ValueError: + continue + candidate = temp_workspace_root / relative_editable + if candidate.exists(): + temp_internal_editables.append(candidate) + + # Execute lower-bound trials per dependency and accumulate final replacement strings for persistence. + dependency_results: list[DependencyOutcome] = [] + replacements: dict[str, str] = {} + package_label = f"{plan.project_path} ({plan.package_name})" + + for target in targets: + versions = catalog.get(target.name) + outcome = _optimize_dependency( + temp_pyproject=temp_pyproject, + dependency=target, + available_versions=versions, + tasks=tasks, + internal_editables=temp_internal_editables, + dry_run=dry_run, + max_candidates=max_candidates, + timeout_seconds=timeout_seconds, + package_label=package_label, + include_dev_group=plan.include_dev_group, + include_dev_extra=plan.include_dev_extra, + optional_extras=plan.optional_extras, + ) + dependency_results.append(outcome) + if outcome.changed: + for old, new in zip(outcome.original_requirements, outcome.final_requirements, strict=True): + replacements[old] = new + + return PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=tasks, + changed=bool(replacements), + dependencies=dependency_results, + replacements=replacements, + skipped=skipped, + ) + + +def _write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False)) + + +def _to_json(package_outcome: PackageOutcome) -> dict: + return { + "project_path": package_outcome.project_path, + "package_name": package_outcome.package_name, + "tasks": package_outcome.tasks, + "changed": package_outcome.changed, + "skipped": package_outcome.skipped, + "error": package_outcome.error, + "dependencies": [ + { + "name": dependency.name, + "changed": dependency.changed, + "original_requirements": dependency.original_requirements, + "final_requirements": dependency.final_requirements, + "candidate_versions": dependency.candidate_versions, + "attempted_versions": dependency.attempted_versions, + "skipped_reason": dependency.skipped_reason, + "attempts": [ + { + "trial_lower": attempt.trial_lower, + "status": attempt.status, + "error": attempt.error, + } + for attempt in dependency.attempts + ], + } + for dependency in package_outcome.dependencies + ], + } + + +def _apply_package_replacements(path: Path, replacements: dict[str, str]) -> None: + if not replacements: + return + _replace_requirements(path, list(replacements.items())) + _format_dependency_arrays_multiline(path) + + +def main() -> None: + """Run package-by-package dependency lower-bound discovery and updates.""" + parser = argparse.ArgumentParser( + description=( + "Lower dependency bounds per package, run lint+test in isolated uv envs, " + "and write a JSON report while updating pyproject files." + ) + ) + parser.add_argument( + "--packages", + nargs="*", + default=None, + help="Optional package filters by workspace path (e.g., packages/core) or package name.", + ) + parser.add_argument( + "--dependencies", + nargs="*", + default=None, + help="Optional dependency-name filters (normalized to lowercase).", + ) + parser.add_argument( + "--parallelism", + type=int, + default=max(1, min(os.cpu_count() or 4, 8)), + help="Number of packages to process concurrently.", + ) + parser.add_argument( + "--max-candidates", + type=int, + default=0, + help="Maximum candidate lower bounds per dependency (0 = no limit).", + ) + parser.add_argument( + "--output-json", + default="scripts/dependencies/dependency-lower-bound-results.json", + help="Path to incremental JSON output report.", + ) + parser.add_argument( + "--version-source", + choices=("pypi", "lock"), + default="pypi", + help="Version source for candidate lower bounds.", + ) + parser.add_argument( + "--timeout-seconds", + type=int, + default=1200, + help="Timeout per task command execution.", + ) + parser.add_argument("--dry-run", action="store_true", help="Validate candidates but do not update pyprojects.") + args = parser.parse_args() + + workspace_pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" + workspace_root = workspace_pyproject.parent + package_filters = {value for value in (args.packages or []) if value and value != "*"} or None + dependency_filters = {name.lower() for name in args.dependencies} if args.dependencies else None + output_json_path = (workspace_root / args.output_json).resolve() + + # Phase 1: prepare shared workspace metadata and collect package execution plans. + package_map = _build_workspace_package_map(workspace_root) + internal_graph = _build_internal_graph(workspace_root, package_map) + lock_versions = _load_lock_versions(workspace_root) + catalog = VersionCatalog(lock_versions=lock_versions, source=args.version_source) + + plans: list[PackagePlan] = [] + for project_path in sorted(set(discover_projects(workspace_pyproject))): + pyproject_file = workspace_root / project_path / "pyproject.toml" + if not pyproject_file.exists(): + print(f"[yellow]Skipping {project_path}: missing pyproject.toml[/yellow]") + continue + package_name = _load_package_name(pyproject_file) + with pyproject_file.open("rb") as f: + package_config = tomli.load(f) + project_section = package_config.get("project", {}) + optional_dependencies = project_section.get("optional-dependencies", {}) or {} + dependency_groups = package_config.get("dependency-groups", {}) or {} + if package_filters and str(project_path) not in package_filters and package_name not in package_filters: + continue + plans.append( + PackagePlan( + project_path=project_path, + package_name=package_name, + pyproject_path=pyproject_file, + internal_editables=_resolve_internal_editables(package_name, package_map, internal_graph), + include_dev_group="dev" in dependency_groups, + include_dev_extra="dev" in optional_dependencies, + optional_extras=sorted(name for name in optional_dependencies if name not in {"all", "dev"}), + ) + ) + + if not plans: + print("[yellow]No packages matched the selection.[/yellow]") + return + + # Phase 2: initialize incremental report state before running package validations in parallel. + report: dict = { + "started_at": _utc_now(), + "workspace_root": str(workspace_root), + "version_source": args.version_source, + "dry_run": args.dry_run, + "packages": [], + "summary": { + "packages_total": len(plans), + "packages_changed": 0, + "dependencies_changed": 0, + }, + } + _write_json(output_json_path, report) + print(f"[cyan]Writing dependency-lower-bound report to {output_json_path}[/cyan]") + + package_outcomes: list[PackageOutcome] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.parallelism)) as executor: + future_to_plan = { + executor.submit( + _process_package, + plan, + catalog=catalog, + dependency_filters=dependency_filters, + dry_run=args.dry_run, + max_candidates=args.max_candidates, + timeout_seconds=args.timeout_seconds, + ): plan + for plan in plans + } + + for future in concurrent.futures.as_completed(future_to_plan): + plan = future_to_plan[future] + try: + outcome = future.result() + except Exception as exc: + outcome = PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=[], + changed=False, + dependencies=[], + replacements={}, + skipped=[], + error=str(exc), + ) + package_outcomes.append(outcome) + + if outcome.changed and not args.dry_run: + _apply_package_replacements(plan.pyproject_path, outcome.replacements) + + # Phase 3: aggregate outcomes, persist incremental JSON snapshots, and emit per-package progress. + report["packages"].append(_to_json(outcome)) + report["summary"]["packages_changed"] = sum(1 for value in package_outcomes if value.changed) + report["summary"]["dependencies_changed"] = sum( + 1 for value in package_outcomes for dependency in value.dependencies if dependency.changed + ) + report["updated_at"] = _utc_now() + _write_json(output_json_path, report) + + if outcome.error: + print(f"[red]{plan.project_path}: package execution error[/red]") + elif outcome.changed: + print(f"[green]{plan.project_path}: updated dependency lower bounds[/green]") + else: + print(f"[yellow]{plan.project_path}: no changes[/yellow]") + + print( + "[bold]Done.[/bold] " + f"packages_changed={report['summary']['packages_changed']}, " + f"dependencies_changed={report['summary']['dependencies_changed']}" + ) + + +if __name__ == "__main__": + main() diff --git a/python/scripts/dependencies/_dependency_bounds_runtime.py b/python/scripts/dependencies/_dependency_bounds_runtime.py new file mode 100644 index 0000000000..73cbb2f1d3 --- /dev/null +++ b/python/scripts/dependencies/_dependency_bounds_runtime.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: INP001 + +"""Shared runtime helpers for dependency-bound validation commands.""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +import tomli +from packaging.requirements import InvalidRequirement, Requirement + +_TOOL_REQUIREMENT_NAMES = { + "mypy", + "poethepoet", + "pyright", + "pytest", + "pytest-asyncio", + "pytest-cov", + "pytest-retry", + "pytest-timeout", + "pytest-xdist", + "ruff", +} + +_ADDITIONAL_RUNTIME_REQUIREMENTS = ( + "graphviz", + "opentelemetry-exporter-otlp-proto-grpc", + "opentelemetry-exporter-otlp-proto-http", +) + +# Run pyright through the current interpreter so its import resolution matches the uv-created environment. +_PYRIGHT_COMMAND = ( + "import subprocess, sys; " + "raise SystemExit(subprocess.call([sys.executable, '-m', 'pyright', '--pythonpath', sys.executable]))" +) + + +@lru_cache(maxsize=8) +def load_runtime_tool_requirements(workspace_root: str) -> list[str]: + """Load shared tool requirements used by package test and typing tasks.""" + workspace_path = Path(workspace_root) + pyproject_path = workspace_path / "pyproject.toml" + data = tomli.loads(pyproject_path.read_text()) + dev_requirements = data.get("dependency-groups", {}).get("dev", []) or [] + + # `uv run --isolated` starts from a clean environment, so the validator has to re-attach the + # shared tooling that package-level poe tasks expect to find. + runtime_requirements: list[str] = [] + for requirement in dev_requirements: + if not isinstance(requirement, str): + continue + try: + parsed = Requirement(requirement) + except InvalidRequirement: + continue + if parsed.name.lower() in _TOOL_REQUIREMENT_NAMES: + runtime_requirements.append(requirement) + return runtime_requirements + + +def extend_command_with_runtime_tools(command: list[str], workspace_root: Path) -> None: + """Append shared tooling requirements to a uv run command.""" + # Mirror the repo-wide test/lint toolchain inside the temporary environment before adding the task. + for requirement in load_runtime_tool_requirements(str(workspace_root.resolve())): + command.extend(["--with", requirement]) + for requirement in _ADDITIONAL_RUNTIME_REQUIREMENTS: + command.extend(["--with", requirement]) + + +def extend_command_with_task(command: list[str], task_name: str) -> None: + """Append the command needed to execute one validation task.""" + if task_name == "pyright": + command.extend(["python", "-c", _PYRIGHT_COMMAND]) + return + + command.extend(["python", "-m", "poethepoet", task_name]) + + +def next_zero_major_minor_boundary(version_text: str) -> str: + """Return the exclusive upper bound for the next 0.x minor after the given version.""" + from packaging.version import Version + + version = Version(version_text) + return f"0.{version.minor + 1}.0" diff --git a/python/scripts/dependencies/_dependency_bounds_upper_impl.py b/python/scripts/dependencies/_dependency_bounds_upper_impl.py new file mode 100644 index 0000000000..a92d16cd7e --- /dev/null +++ b/python/scripts/dependencies/_dependency_bounds_upper_impl.py @@ -0,0 +1,1275 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: INP001, S404, S603 + +"""Raise dependency upper bounds, validate, and persist the latest passing set.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import os +import re +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path +from urllib import error as urllib_error +from urllib import request as urllib_request + +import tomli +from scripts.dependencies._dependency_bounds_runtime import ( + extend_command_with_runtime_tools, + extend_command_with_task, + next_zero_major_minor_boundary, +) +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import InvalidVersion, Version +from rich import print +from scripts.task_runner import discover_projects, extract_poe_tasks + +CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") +REQ_PATTERN = r"^\s*([A-Za-z0-9_.-]+(?:\[[^\]]+\])?)\s*(.*?)\s*$" +SECTION_HEADER_PATTERN = re.compile(r"^\s*\[([^\]]+)\]\s*$") +INLINE_ARRAY_ASSIGNMENT_PATTERN = re.compile( + r"^(?P\s*)(?P[A-Za-z0-9_.-]+)\s*=\s*\[(?P.*)\](?P\s*(?:#.*)?)$" +) +QUOTED_STRING_PATTERN = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') + + +@dataclass +class RequirementEntry: + """A parsed requirement entry from pyproject dependencies.""" + + raw: str + name: str + name_extras: str + marker: str | None + spec_parts: list[str] + lower_version: Version | None + upper_index: int | None + upper_version: Version | None + exact_index: int | None = None + exact_version: Version | None = None + + def with_upper(self, upper: Version) -> str: + """Return a new requirement with the given exclusive upper bound.""" + updated_parts = list(self.spec_parts) + if self.exact_index is not None and self.exact_version is not None: + updated_parts[self.exact_index] = f">={self.exact_version}" + if self.upper_index is not None: + updated_parts[self.upper_index] = f"<{upper}" + else: + updated_parts.append(f"<{upper}") + elif self.upper_index is not None: + updated_parts[self.upper_index] = f"<{upper}" + else: + raise ValueError(f"Requirement has no mutable bound information: {self.raw}") + spec = ",".join(updated_parts) + requirement = f"{self.name_extras}{spec}" + if self.marker: + requirement += f"; {self.marker}" + return requirement + + +@dataclass +class DependencyTarget: + """A dependency to optimize within one package.""" + + name: str + entries: list[RequirementEntry] + lower_version: Version | None + upper_version: Version + allow_prerelease_candidates: bool + + @property + def original_requirements(self) -> list[str]: + """Return original requirement strings for this dependency group.""" + return [entry.raw for entry in self.entries] + + +@dataclass +class DependencyAttempt: + """A single upper-bound trial for one dependency.""" + + trial_upper: str + status: str + error: str | None = None + + +@dataclass +class DependencyOutcome: + """Final outcome for one dependency optimization.""" + + name: str + changed: bool + original_requirements: list[str] + final_requirements: list[str] + candidate_versions: list[str] + attempted_versions: list[str] + attempts: list[DependencyAttempt] + skipped_reason: str | None = None + + +@dataclass +class PackagePlan: + """Execution plan for a package.""" + + project_path: Path + package_name: str + pyproject_path: Path + internal_editables: list[Path] + include_dev_group: bool + include_dev_extra: bool + optional_extras: list[str] + + +@dataclass +class PackageOutcome: + """Execution outcome for a package.""" + + project_path: str + package_name: str + tasks: list[str] + changed: bool + dependencies: list[DependencyOutcome] + replacements: dict[str, str] + skipped: list[str] + error: str | None = None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _truncate_error(stdout: str, stderr: str, *, max_chars: int = 2000) -> str: + combined = "\n".join(part for part in [stderr.strip(), stdout.strip()] if part) + if len(combined) <= max_chars: + return combined + return f"...\n{combined[-max_chars:]}" + + +def _parse_requirement(requirement: str) -> RequirementEntry | None: + match = re.match(REQ_PATTERN, requirement) + if not match: + return None + name_extras = match.group(1) + rest = match.group(2).strip() + marker = None + if ";" in rest: + spec_part, marker_part = rest.split(";", 1) + spec = spec_part.strip() + marker = marker_part.strip() + else: + spec = rest + if not spec: + return None + + spec_parts = [part.strip() for part in spec.split(",") if part.strip()] + if not spec_parts: + return None + + lower_version: Version | None = None + upper_version: Version | None = None + upper_index: int | None = None + exact_version: Version | None = None + exact_index: int | None = None + + for index, part in enumerate(spec_parts): + if part.startswith((">=", ">")): + raw_version = part[2:].strip() if part.startswith(">=") else part[1:].strip() + try: + parsed = Version(raw_version) + except InvalidVersion: + continue + if lower_version is None or parsed > lower_version: + lower_version = parsed + elif part.startswith(("==", "===")): + raw_version = part[3:].strip() if part.startswith("===") else part[2:].strip() + try: + parsed = Version(raw_version) + except InvalidVersion: + continue + exact_version = parsed + exact_index = index + if lower_version is None or parsed > lower_version: + lower_version = parsed + if part.startswith(("<", "<=")): + raw_version = part[2:].strip() if part.startswith("<=") else part[1:].strip() + try: + parsed = Version(raw_version) + except InvalidVersion: + continue + if upper_version is None or parsed < upper_version: + upper_version = parsed + upper_index = index + + if upper_version is None and exact_version is None: + return None + name = name_extras.split("[", 1)[0].lower() + return RequirementEntry( + raw=requirement, + name=name, + name_extras=name_extras, + marker=marker, + spec_parts=spec_parts, + lower_version=lower_version, + upper_index=upper_index, + upper_version=upper_version, + exact_index=exact_index, + exact_version=exact_version, + ) + + +def _select_latest_dev_version(versions: list[Version]) -> Version | None: + if not versions: + return None + stable_versions = [version for version in versions if not version.is_prerelease] + if stable_versions: + return stable_versions[-1] + return versions[-1] + + +@lru_cache(maxsize=8) +def _load_workspace_package_versions(workspace_root: str) -> dict[str, Version]: + workspace_path = Path(workspace_root) + versions: dict[str, Version] = {} + for package_pyproject in sorted((workspace_path / "packages").glob("*/pyproject.toml")): + with package_pyproject.open("rb") as f: + package_data = tomli.load(f) + project_section = package_data.get("project", {}) or {} + package_name = str(project_section.get("name", "")).strip().lower() + package_version = project_section.get("version") + if not package_name or not package_version: + continue + try: + versions[package_name] = Version(str(package_version)) + except InvalidVersion: + continue + return versions + + +def _collect_dev_pin_replacements( + pyproject_file: Path, + *, + catalog: VersionCatalog, +) -> dict[str, str]: + with pyproject_file.open("rb") as f: + data = tomli.load(f) + project = data.get("project", {}) or {} + optional_dependencies = project.get("optional-dependencies", {}) or {} + dependency_groups = data.get("dependency-groups", {}) or {} + workspace_versions = _load_workspace_package_versions(str(pyproject_file.parent.parent.parent.resolve())) + + dev_requirements: list[str] = [] + dev_requirements.extend( + requirement for requirement in (optional_dependencies.get("dev", []) or []) if isinstance(requirement, str) + ) + dev_requirements.extend( + requirement for requirement in (dependency_groups.get("dev", []) or []) if isinstance(requirement, str) + ) + + seen_requirements: set[str] = set() + replacements: dict[str, str] = {} + for requirement in dev_requirements: + if requirement in seen_requirements: + continue + seen_requirements.add(requirement) + + # Refresh exact dev pins while we already have the file open so outdated test tooling + # does not masquerade as a runtime dependency compatibility failure. + try: + parsed_requirement = Requirement(requirement) + except InvalidRequirement: + continue + if parsed_requirement.url is not None: + continue + dependency_name = parsed_requirement.name.lower() + if dependency_name.startswith("agent-framework"): + latest_version = workspace_versions.get(dependency_name) + else: + latest_version = _select_latest_dev_version(catalog.get_lock(dependency_name)) + if latest_version is None: + latest_version = _select_latest_dev_version(catalog.get(dependency_name)) + if latest_version is None: + continue + + extras = f"[{','.join(sorted(parsed_requirement.extras))}]" if parsed_requirement.extras else "" + marker = f"; {parsed_requirement.marker}" if parsed_requirement.marker else "" + pinned_requirement = f"{parsed_requirement.name}{extras}=={latest_version}{marker}" + if requirement != pinned_requirement: + replacements[requirement] = pinned_requirement + + return replacements + + +def _is_dependency_array_assignment(section: str, key: str) -> bool: + if section == "project": + return key == "dependencies" + return section in {"project.optional-dependencies", "dependency-groups"} + + +def _extract_inline_array_items(array_body: str) -> list[str] | None: + items = [match.group(0) for match in QUOTED_STRING_PATTERN.finditer(array_body)] + remainder = QUOTED_STRING_PATTERN.sub("", array_body) + if remainder.replace(",", "").strip(): + return None + return items + + +def _format_dependency_arrays_multiline(path: Path) -> None: + original_text = path.read_text() + lines = original_text.splitlines() + current_section = "" + updated_lines: list[str] = [] + changed = False + + for line in lines: + section_match = SECTION_HEADER_PATTERN.match(line) + if section_match: + current_section = section_match.group(1).strip() + updated_lines.append(line) + continue + + assignment_match = INLINE_ARRAY_ASSIGNMENT_PATTERN.match(line) + if assignment_match is None: + updated_lines.append(line) + continue + + indent = assignment_match.group("indent") + key = assignment_match.group("key") + body = assignment_match.group("body") + suffix = (assignment_match.group("suffix") or "").rstrip() + if not _is_dependency_array_assignment(current_section, key): + updated_lines.append(line) + continue + + items = _extract_inline_array_items(body) + if items is None or len(items) == 0: + updated_lines.append(line) + continue + + changed = True + updated_lines.append(f"{indent}{key} = [") + updated_lines.extend(f"{indent} {item}," for item in items) + closing_line = f"{indent}]" + if suffix: + closing_line = f"{closing_line}{suffix}" + updated_lines.append(closing_line) + + if not changed: + return + + updated_text = "\n".join(updated_lines) + if original_text.endswith("\n"): + updated_text += "\n" + path.write_text(updated_text) + + +def _replace_requirements(path: Path, replacements: list[tuple[str, str]]) -> None: + text = path.read_text() + updated_text = text + for old, new in replacements: + replaced = False + old_double = f'"{old}"' + old_single = f"'{old}'" + new_double = f'"{new}"' + new_single = f"'{new}'" + if old_double in updated_text: + updated_text = updated_text.replace(old_double, new_double) + replaced = True + if old_single in updated_text: + updated_text = updated_text.replace(old_single, new_single) + replaced = True + if not replaced: + raise ValueError(f"Could not find dependency string in {path}: {old}") + if updated_text != text: + path.write_text(updated_text) + + +def _load_lock_versions(workspace_root: Path) -> dict[str, list[Version]]: + lock_file = workspace_root / "uv.lock" + if not lock_file.exists(): + return {} + with lock_file.open("rb") as f: + lock_data = tomli.load(f) + versions_by_name: dict[str, set[Version]] = {} + for package_data in lock_data.get("package", []): + package_name = str(package_data.get("name", "")).lower() + package_version = package_data.get("version") + if not package_name or not package_version: + continue + try: + parsed = Version(str(package_version)) + except InvalidVersion: + continue + versions_by_name.setdefault(package_name, set()).add(parsed) + return {name: sorted(values) for name, values in versions_by_name.items()} + + +class VersionCatalog: + """Cache and fetch available dependency versions.""" + + def __init__(self, lock_versions: dict[str, list[Version]], source: str) -> None: + """Initialize the catalog with lock-based fallback and fetch source.""" + self._lock_versions = lock_versions + self._source = source + self._cache: dict[str, list[Version]] = {} + self._lock = threading.Lock() + + def get(self, package_name: str) -> list[Version]: + """Return cached or fetched versions for a package name.""" + with self._lock: + cached = self._cache.get(package_name) + if cached is not None: + return cached + versions = self._fetch(package_name) + with self._lock: + self._cache[package_name] = versions + return versions + + def get_lock(self, package_name: str) -> list[Version]: + """Return lockfile versions for a package name.""" + return self._lock_versions.get(package_name, []) + + def _fetch(self, package_name: str) -> list[Version]: + if self._source == "lock": + return self._lock_versions.get(package_name, []) + + try: + url = f"https://pypi.org/pypi/{package_name}/json" + with urllib_request.urlopen(url, timeout=20) as response: + payload = json.load(response) + except (urllib_error.URLError, TimeoutError, json.JSONDecodeError): + return self._lock_versions.get(package_name, []) + + versions: set[Version] = set() + for raw_version, files in payload.get("releases", {}).items(): + if not files: + continue + non_yanked = any(not bool(file_info.get("yanked", False)) for file_info in files) + if not non_yanked: + continue + try: + versions.add(Version(raw_version)) + except InvalidVersion: + continue + if versions: + return sorted(versions) + return self._lock_versions.get(package_name, []) + + +def _load_package_name(pyproject_file: Path) -> str: + with pyproject_file.open("rb") as f: + data = tomli.load(f) + return str(data["project"]["name"]) + + +def _extract_requirement_name(requirement: str) -> str | None: + try: + return Requirement(requirement).name.lower() + except InvalidRequirement: + return None + + +def _select_validation_tasks(available_tasks: set[str]) -> list[str]: + check_task = next((task for task in CHECK_TASK_PRIORITY if task in available_tasks), None) + tasks: list[str] = [] + if check_task: + tasks.append(check_task) + if "test" in available_tasks and "test" not in tasks: + tasks.append("test") + return tasks + + +def _build_workspace_package_map(workspace_root: Path) -> dict[str, Path]: + package_map: dict[str, Path] = {} + for pyproject_file in sorted((workspace_root / "packages").glob("*/pyproject.toml")): + with pyproject_file.open("rb") as f: + data = tomli.load(f) + package_name = str(data.get("project", {}).get("name", "")).strip() + if package_name: + package_map[package_name] = pyproject_file.parent + return package_map + + +def _build_internal_graph(workspace_root: Path, package_map: dict[str, Path]) -> dict[str, set[str]]: + graph: dict[str, set[str]] = {} + for package_name, package_path in package_map.items(): + pyproject_file = package_path / "pyproject.toml" + with pyproject_file.open("rb") as f: + data = tomli.load(f) + project = data.get("project", {}) or {} + dependencies: list[str] = list(project.get("dependencies", []) or []) + for values in (project.get("optional-dependencies", {}) or {}).values(): + dependencies.extend([value for value in (values or []) if isinstance(value, str)]) + for values in (data.get("dependency-groups", {}) or {}).values(): + dependencies.extend([value for value in (values or []) if isinstance(value, str)]) + internal = set() + for dependency in dependencies: + dependency_name = _extract_requirement_name(dependency) + if dependency_name is None: + continue + if dependency_name.startswith("agent-framework"): + for candidate_name in package_map: + if candidate_name.lower() == dependency_name: + internal.add(candidate_name) + break + graph[package_name] = internal + return graph + + +def _resolve_internal_editables( + package_name: str, package_map: dict[str, Path], graph: dict[str, set[str]] +) -> list[Path]: + visited: set[str] = set() + stack = [package_name] + results: set[Path] = set() + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + for dependency_name in graph.get(current, set()): + dependency_path = package_map.get(dependency_name) + if dependency_path and dependency_name != package_name: + results.add(dependency_path.resolve()) + stack.append(dependency_name) + return sorted(results) + + +def _collect_targets( + pyproject_file: Path, + *, + dependency_filters: set[str] | None, +) -> tuple[list[DependencyTarget], list[str]]: + with pyproject_file.open("rb") as f: + data = tomli.load(f) + project = data.get("project", {}) + dependencies: list[str] = list(project.get("dependencies", []) or []) + + grouped: dict[str, list[RequirementEntry]] = {} + skipped: list[str] = [] + + for dependency in dependencies: + parsed = _parse_requirement(dependency) + if not parsed: + continue + if parsed.name.startswith("agent-framework"): + continue + if dependency_filters and parsed.name not in dependency_filters: + continue + grouped.setdefault(parsed.name, []).append(parsed) + + targets: list[DependencyTarget] = [] + for dependency_name, entries in sorted(grouped.items()): + if not entries: + continue + # A dependency can be repeated across sections/extras. Only optimize it when every + # occurrence agrees on the current bound shape so we never rewrite inconsistent specs. + allow_prerelease_candidates = any( + ( + (entry.lower_version is not None and entry.lower_version.is_prerelease) + or (entry.upper_version is not None and entry.upper_version.is_prerelease) + or (entry.exact_version is not None and entry.exact_version.is_prerelease) + ) + for entry in entries + ) + upper_entries = [entry for entry in entries if entry.upper_version is not None] + exact_entries = [entry for entry in entries if entry.exact_version is not None] + + if upper_entries: + if len(upper_entries) != len(entries): + skipped.append(f"{dependency_name}: mixed bounded and unbounded/exact requirements in package") + continue + first_upper = upper_entries[0].upper_version + if first_upper is None: + skipped.append(f"{dependency_name}: missing upper bound value") + continue + if any(entry.upper_version != first_upper for entry in upper_entries[1:]): + skipped.append(f"{dependency_name}: conflicting upper bounds in package") + continue + lower_versions = [entry.lower_version for entry in entries if entry.lower_version is not None] + lower = max(lower_versions) if lower_versions else None + targets.append( + DependencyTarget( + name=dependency_name, + entries=entries, + lower_version=lower, + upper_version=first_upper, + allow_prerelease_candidates=allow_prerelease_candidates, + ) + ) + continue + + if exact_entries and len(exact_entries) == len(entries): + first_exact = exact_entries[0].exact_version + if first_exact is None: + skipped.append(f"{dependency_name}: missing exact version value") + continue + if any(entry.exact_version != first_exact for entry in exact_entries[1:]): + skipped.append(f"{dependency_name}: conflicting exact pins in package") + continue + targets.append( + DependencyTarget( + name=dependency_name, + entries=entries, + lower_version=first_exact, + upper_version=first_exact, + allow_prerelease_candidates=allow_prerelease_candidates, + ) + ) + continue + + skipped.append(f"{dependency_name}: no usable upper or exact bound to optimize") + return targets, skipped + + +def _build_trial_bounds( + versions: list[Version], + *, + lower: Version | None, + current_upper: Version, + allow_prerelease: bool, + max_candidates: int, +) -> list[Version]: + # Candidate generation mirrors the policy encoded in pyproject bounds: + # prerelease tracks only advance one prerelease step, any 0.x dependency may + # span multiple validated minor lines, and stable tracks probe newer versions + # from highest to lowest. + if lower is not None and lower.is_prerelease: + if lower.pre is not None: + pre_tag, pre_num = lower.pre + next_prerelease = Version(f"{lower.base_version}{pre_tag}{pre_num + 1}") + elif lower.dev is not None: + next_prerelease = Version(f"{lower.base_version}.dev{lower.dev + 1}") + else: + next_prerelease = None + if next_prerelease is None: + return [] + return [version for version in versions if version == next_prerelease and version > current_upper] + + if lower is not None and lower.major == 0: + candidates = [version for version in versions if version.major == 0 and version > lower] + if not allow_prerelease: + candidates = [version for version in candidates if not version.is_prerelease] + candidate_bounds = sorted( + { + Version(next_zero_major_minor_boundary(str(version))) + for version in candidates + if version >= current_upper + }, + reverse=True, + ) + if max_candidates > 0: + return candidate_bounds[:max_candidates] + return candidate_bounds + + candidates = [version for version in versions if version > current_upper and (lower is None or version > lower)] + # `packaging` treats .dev/.a/.b/.rc as prereleases; only probe them when current spec already uses them. + if not allow_prerelease: + candidates = [version for version in candidates if not version.is_prerelease] + candidates.sort(reverse=True) + if max_candidates > 0: + return candidates[:max_candidates] + return candidates + + +def _select_upper_probe_version( + versions: list[Version], + *, + lower: Version | None, + upper_bound: Version, + allow_prerelease: bool, +) -> Version | None: + """Return the newest concrete version that would be allowed by a candidate upper bound.""" + probe_versions = [ + version for version in versions if version < upper_bound and (lower is None or version >= lower) + ] + if not allow_prerelease: + probe_versions = [version for version in probe_versions if not version.is_prerelease] + return probe_versions[-1] if probe_versions else None + + +def _run_tasks( + project_dir: Path, + *, + workspace_root: Path, + tasks: list[str], + internal_editables: list[Path], + resolution: str, + dependency_pin: tuple[str, Version] | None, + include_dev_group: bool, + include_dev_extra: bool, + optional_extras: list[str], + timeout_seconds: int, +) -> tuple[bool, str | None]: + # Every probe runs inside a fresh isolated uv environment. Clearing VIRTUAL_ENV avoids + # leaking the caller's active environment into the subprocess and keeps validation from + # mutating the repo's active `.venv`. + env = dict(os.environ) + env["UV_PRERELEASE"] = "allow" + env.pop("VIRTUAL_ENV", None) + for task_name in tasks: + command = [ + "uv", + "--no-progress", + "--directory", + str(project_dir), + "run", + "--isolated", + "--resolution", + resolution, + "--prerelease", + "allow", + "--quiet", + ] + extend_command_with_runtime_tools(command, workspace_root) + if include_dev_group: + command.extend(["--group", "dev"]) + if include_dev_extra: + command.extend(["--extra", "dev"]) + for extra_name in optional_extras: + command.extend(["--extra", extra_name]) + for editable_path in internal_editables: + command.extend(["--with-editable", str(editable_path)]) + if dependency_pin is not None: + dependency_name, dependency_version = dependency_pin + command.extend(["--with", f"{dependency_name}=={dependency_version}"]) + extend_command_with_task(command, task_name) + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + env=env, + ) + except subprocess.TimeoutExpired: + return False, f"Timeout while running task '{task_name}'." + if result.returncode != 0: + return ( + False, + f"Task '{task_name}' failed.\n{_truncate_error(result.stdout, result.stderr)}", + ) + return True, None + + +def _optimize_dependency( + *, + temp_pyproject: Path, + dependency: DependencyTarget, + available_versions: list[Version], + tasks: list[str], + internal_editables: list[Path], + dry_run: bool, + max_candidates: int, + timeout_seconds: int, + package_label: str, + include_dev_group: bool, + include_dev_extra: bool, + optional_extras: list[str], +) -> DependencyOutcome: + # Build descending candidate trial bounds from the current constraint window. + candidates = _build_trial_bounds( + available_versions, + lower=dependency.lower_version, + current_upper=dependency.upper_version, + allow_prerelease=dependency.allow_prerelease_candidates, + max_candidates=max_candidates, + ) + candidate_versions = [str(candidate) for candidate in candidates] + attempted_versions: list[str] = [] + attempts: list[DependencyAttempt] = [] + final_requirements = dependency.original_requirements + + # Baselines answer two questions before the script widens any range: + # does the current floor still work, and does the newest version already in range still work? + in_range_versions = [ + version + for version in available_versions + if (dependency.lower_version is None or version >= dependency.lower_version) + and (dependency.upper_version is None or version < dependency.upper_version) + ] + if not dependency.allow_prerelease_candidates: + in_range_versions = [version for version in in_range_versions if not version.is_prerelease] + baseline_trials: list[tuple[str, Version, str]] = [] + if dependency.upper_version is not None and dependency.lower_version == dependency.upper_version: + baseline_trials.append(("current_fixed", dependency.upper_version, "highest")) + else: + if dependency.lower_version is not None: + lower_probe = next( + (version for version in in_range_versions if version >= dependency.lower_version), + dependency.lower_version, + ) + baseline_trials.append(("current_lower", lower_probe, "lowest-direct")) + if dependency.upper_version is not None: + upper_probe = in_range_versions[-1] if in_range_versions else dependency.upper_version + baseline_trials.append(("current_upper", upper_probe, "highest")) + + for baseline_name, baseline_version, baseline_resolution in baseline_trials: + attempted_versions.append(str(baseline_version)) + print( + f"[cyan]{package_label} :: {dependency.name} :: baseline {baseline_name} " + f"({baseline_resolution}) [{baseline_version}] [/cyan]" + ) + success, error = _run_tasks( + temp_pyproject.parent, + workspace_root=temp_pyproject.parent.parent.parent, + tasks=tasks, + internal_editables=internal_editables, + resolution=baseline_resolution, + dependency_pin=(dependency.name, baseline_version), + include_dev_group=include_dev_group, + include_dev_extra=include_dev_extra, + optional_extras=optional_extras, + timeout_seconds=timeout_seconds, + ) + if success: + attempts.append( + DependencyAttempt( + trial_upper=str(baseline_version), + status=f"{baseline_name}_passed", + ) + ) + continue + + attempts.append( + DependencyAttempt( + trial_upper=str(baseline_version), + status="failed", + error=error, + ) + ) + return DependencyOutcome( + name=dependency.name, + changed=False, + original_requirements=dependency.original_requirements, + final_requirements=dependency.original_requirements, + candidate_versions=candidate_versions, + attempted_versions=attempted_versions, + attempts=attempts, + skipped_reason=f"Baseline validation failed at {baseline_name}.", + ) + + if not candidates: + return DependencyOutcome( + name=dependency.name, + changed=False, + original_requirements=dependency.original_requirements, + final_requirements=dependency.original_requirements, + candidate_versions=[], + attempted_versions=attempted_versions, + attempts=attempts, + skipped_reason="No higher candidate bounds found.", + ) + + # Probe candidates from highest to lowest; keep the first passing upper-bound rewrite. + for candidate in candidates: + probe_version = _select_upper_probe_version( + available_versions, + lower=dependency.lower_version, + upper_bound=candidate, + allow_prerelease=dependency.allow_prerelease_candidates, + ) + if probe_version is None: + attempts.append( + DependencyAttempt( + trial_upper=str(candidate), + status="skipped", + error="No concrete version available within the candidate upper bound.", + ) + ) + continue + attempted_versions.append(str(probe_version)) + + print(f"[cyan]{package_label} :: {dependency.name} -> <{candidate} (probe {probe_version})[/cyan]") + success, error = _run_tasks( + temp_pyproject.parent, + workspace_root=temp_pyproject.parent.parent.parent, + tasks=tasks, + internal_editables=internal_editables, + resolution="highest", + dependency_pin=(dependency.name, probe_version), + include_dev_group=include_dev_group, + include_dev_extra=include_dev_extra, + optional_extras=optional_extras, + timeout_seconds=timeout_seconds, + ) + if success: + attempts.append(DependencyAttempt(trial_upper=str(candidate), status="passed")) + final_requirements = [entry.with_upper(candidate) for entry in dependency.entries] + break + + attempts.append(DependencyAttempt(trial_upper=str(candidate), status="failed", error=error)) + continue + + changed = final_requirements != dependency.original_requirements + return DependencyOutcome( + name=dependency.name, + changed=changed, + original_requirements=dependency.original_requirements, + final_requirements=final_requirements, + candidate_versions=candidate_versions, + attempted_versions=attempted_versions, + attempts=attempts, + ) + + +def _process_package( + plan: PackagePlan, + *, + workspace_root: Path, + catalog: VersionCatalog, + dependency_filters: set[str] | None, + dry_run: bool, + max_candidates: int, + timeout_seconds: int, +) -> PackageOutcome: + pyproject_file = plan.pyproject_path + source_workspace_root = workspace_root.resolve() + available_tasks = extract_poe_tasks(pyproject_file) + tasks = _select_validation_tasks(available_tasks) + if not tasks: + return PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=[], + changed=False, + dependencies=[], + replacements={}, + skipped=["No check/test task combination found."], + ) + + with tempfile.TemporaryDirectory(prefix=f"dep-range-{plan.project_path.name}-") as temp_dir: + temp_root = Path(temp_dir) + temp_workspace_root = temp_root / source_workspace_root.name + # Copy the whole workspace so uv workspace sources and editable internal packages resolve + # the same way they do in the real checkout while keeping trial rewrites fully isolated. + shutil.copytree( + source_workspace_root, + temp_workspace_root, + ignore=shutil.ignore_patterns( + ".git", + ".venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "node_modules", + "dist", + ), + ) + + temp_packages_dir = temp_workspace_root / "packages" + if temp_packages_dir.exists(): + for package_dir in temp_packages_dir.iterdir(): + if package_dir.is_dir() and not (package_dir / "pyproject.toml").exists(): + shutil.rmtree(package_dir) + + temp_project_dir = temp_workspace_root / plan.project_path + temp_pyproject = temp_project_dir / "pyproject.toml" + temp_internal_editables: list[Path] = [] + for editable in plan.internal_editables: + try: + relative_editable = editable.resolve().relative_to(source_workspace_root) + except ValueError: + continue + candidate = temp_workspace_root / relative_editable + if candidate.exists(): + temp_internal_editables.append(candidate) + + dev_replacements = _collect_dev_pin_replacements(temp_pyproject, catalog=catalog) + if dev_replacements: + _replace_requirements(temp_pyproject, list(dev_replacements.items())) + print( + f"[cyan]{plan.project_path}: refreshed {len(dev_replacements)} dev dependency pin(s) to latest[/cyan]" + ) + + targets, skipped = _collect_targets(temp_pyproject, dependency_filters=dependency_filters) + + dependency_results: list[DependencyOutcome] = [] + replacements: dict[str, str] = dict(dev_replacements) + package_label = f"{plan.project_path} ({plan.package_name})" + + if not targets: + skipped.append("No eligible dependencies with upper bounds in project.dependencies.") + + # Run per-dependency trial generation + validation in the isolated temp workspace. + for target in targets: + versions = catalog.get(target.name) + outcome = _optimize_dependency( + temp_pyproject=temp_pyproject, + dependency=target, + available_versions=versions, + tasks=tasks, + internal_editables=temp_internal_editables, + dry_run=dry_run, + max_candidates=max_candidates, + timeout_seconds=timeout_seconds, + package_label=package_label, + include_dev_group=plan.include_dev_group, + include_dev_extra=plan.include_dev_extra, + optional_extras=plan.optional_extras, + ) + dependency_results.append(outcome) + if outcome.changed: + for old, new in zip(outcome.original_requirements, outcome.final_requirements, strict=True): + replacements[old] = new + + return PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=tasks, + changed=bool(replacements), + dependencies=dependency_results, + replacements=replacements, + skipped=skipped, + ) + + +def _write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False)) + + +def _to_json(package_outcome: PackageOutcome) -> dict: + return { + "project_path": package_outcome.project_path, + "package_name": package_outcome.package_name, + "tasks": package_outcome.tasks, + "changed": package_outcome.changed, + "skipped": package_outcome.skipped, + "error": package_outcome.error, + "dependencies": [ + { + "name": dependency.name, + "changed": dependency.changed, + "original_requirements": dependency.original_requirements, + "final_requirements": dependency.final_requirements, + "candidate_versions": dependency.candidate_versions, + "attempted_versions": dependency.attempted_versions, + "skipped_reason": dependency.skipped_reason, + "attempts": [ + { + "trial_upper": attempt.trial_upper, + "status": attempt.status, + "error": attempt.error, + } + for attempt in dependency.attempts + ], + } + for dependency in package_outcome.dependencies + ], + } + + +def _apply_package_replacements(path: Path, replacements: dict[str, str]) -> None: + if not replacements: + return + _replace_requirements(path, list(replacements.items())) + _format_dependency_arrays_multiline(path) + + +def main() -> None: + """Run package-by-package dependency upper-bound discovery and updates.""" + parser = argparse.ArgumentParser( + description=( + "Raise dependency upper bounds per package, refresh dev pins to latest exact versions, " + "run check+test in isolated uv envs, and write a JSON report while updating pyproject files." + ) + ) + parser.add_argument( + "--packages", + nargs="*", + default=None, + help="Optional package filters by workspace path (e.g., packages/core) or package name.", + ) + parser.add_argument( + "--dependencies", + nargs="*", + default=None, + help="Optional dependency-name filters (normalized to lowercase).", + ) + parser.add_argument( + "--parallelism", + type=int, + default=max(1, min(os.cpu_count() or 4, 8)), + help="Number of packages to process concurrently.", + ) + parser.add_argument( + "--max-candidates", + type=int, + default=0, + help="Maximum candidate upper bounds per dependency (0 = no limit).", + ) + parser.add_argument( + "--output-json", + default="scripts/dependencies/dependency-range-results.json", + help="Path to incremental JSON output report.", + ) + parser.add_argument( + "--version-source", + choices=("pypi", "lock"), + default="pypi", + help="Version source for candidate upper bounds.", + ) + parser.add_argument( + "--timeout-seconds", + type=int, + default=1200, + help="Timeout per task command execution.", + ) + parser.add_argument("--dry-run", action="store_true", help="Validate candidates but do not update pyprojects.") + args = parser.parse_args() + + # Preparation/target collection: resolve workspace metadata and package execution plans + # up front so each worker can operate independently on a package-local temp copy. + workspace_pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" + workspace_root = workspace_pyproject.parent + package_filters = {value for value in (args.packages or []) if value and value != "*"} or None + dependency_filters = {name.lower() for name in args.dependencies} if args.dependencies else None + output_json_path = (workspace_root / args.output_json).resolve() + + package_map = _build_workspace_package_map(workspace_root) + internal_graph = _build_internal_graph(workspace_root, package_map) + lock_versions = _load_lock_versions(workspace_root) + catalog = VersionCatalog(lock_versions=lock_versions, source=args.version_source) + + plans: list[PackagePlan] = [] + for project_path in sorted(set(discover_projects(workspace_pyproject))): + pyproject_file = workspace_root / project_path / "pyproject.toml" + if not pyproject_file.exists(): + print(f"[yellow]Skipping {project_path}: missing pyproject.toml[/yellow]") + continue + package_name = _load_package_name(pyproject_file) + with pyproject_file.open("rb") as f: + package_config = tomli.load(f) + project_section = package_config.get("project", {}) + optional_dependencies = project_section.get("optional-dependencies", {}) or {} + dependency_groups = package_config.get("dependency-groups", {}) or {} + if package_filters and str(project_path) not in package_filters and package_name not in package_filters: + continue + plans.append( + PackagePlan( + project_path=project_path, + package_name=package_name, + pyproject_path=pyproject_file, + internal_editables=_resolve_internal_editables(package_name, package_map, internal_graph), + include_dev_group="dev" in dependency_groups, + include_dev_extra="dev" in optional_dependencies, + optional_extras=sorted(name for name in optional_dependencies if name not in {"all", "dev"}), + ) + ) + + root_package_name = _load_package_name(workspace_pyproject) + with workspace_pyproject.open("rb") as f: + root_config = tomli.load(f) + root_project_section = root_config.get("project", {}) + root_optional_dependencies = root_project_section.get("optional-dependencies", {}) or {} + root_dependency_groups = root_config.get("dependency-groups", {}) or {} + if ( + not package_filters + or "." in package_filters + or "./" in package_filters + or "root" in package_filters + or root_package_name in package_filters + ): + plans.append( + PackagePlan( + project_path=Path("."), + package_name=root_package_name, + pyproject_path=workspace_pyproject, + internal_editables=[], + include_dev_group="dev" in root_dependency_groups, + include_dev_extra="dev" in root_optional_dependencies, + optional_extras=sorted(name for name in root_optional_dependencies if name not in {"all", "dev"}), + ) + ) + + if not plans: + print("[yellow]No packages matched the selection.[/yellow]") + return + + # Aggregation + persistence/reporting: initialize the incremental JSON report. + report: dict = { + "started_at": _utc_now(), + "workspace_root": str(workspace_root), + "version_source": args.version_source, + "dry_run": args.dry_run, + "packages": [], + "summary": { + "packages_total": len(plans), + "packages_changed": 0, + "dependencies_changed": 0, + }, + } + _write_json(output_json_path, report) + print(f"[cyan]Writing dependency-range report to {output_json_path}[/cyan]") + + package_outcomes: list[PackageOutcome] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.parallelism)) as executor: + future_to_plan = { + executor.submit( + _process_package, + plan, + workspace_root=workspace_root, + catalog=catalog, + dependency_filters=dependency_filters, + dry_run=args.dry_run, + max_candidates=args.max_candidates, + timeout_seconds=args.timeout_seconds, + ): plan + for plan in plans + } + + for future in concurrent.futures.as_completed(future_to_plan): + plan = future_to_plan[future] + try: + outcome = future.result() + except Exception as exc: + outcome = PackageOutcome( + project_path=str(plan.project_path), + package_name=plan.package_name, + tasks=[], + changed=False, + dependencies=[], + replacements={}, + skipped=[], + error=str(exc), + ) + package_outcomes.append(outcome) + + if outcome.changed and not args.dry_run: + _apply_package_replacements(plan.pyproject_path, outcome.replacements) + + # Persist each completed package outcome so long runs keep a live report. + report["packages"].append(_to_json(outcome)) + report["summary"]["packages_changed"] = sum(1 for value in package_outcomes if value.changed) + report["summary"]["dependencies_changed"] = sum( + 1 for value in package_outcomes for dependency in value.dependencies if dependency.changed + ) + report["updated_at"] = _utc_now() + _write_json(output_json_path, report) + + if outcome.error: + print(f"[red]{plan.project_path}: package execution error[/red]") + elif outcome.changed: + print(f"[green]{plan.project_path}: updated dependency bounds[/green]") + else: + print(f"[yellow]{plan.project_path}: no changes[/yellow]") + + print( + "[bold]Done.[/bold] " + f"packages_changed={report['summary']['packages_changed']}, " + f"dependencies_changed={report['summary']['dependencies_changed']}" + ) + + +if __name__ == "__main__": + main() diff --git a/python/scripts/dependencies/upgrade_dev_dependencies.py b/python/scripts/dependencies/upgrade_dev_dependencies.py new file mode 100644 index 0000000000..7ea2067af5 --- /dev/null +++ b/python/scripts/dependencies/upgrade_dev_dependencies.py @@ -0,0 +1,180 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: INP001 + +"""Refresh dev dependency pins across the Python workspace.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +import tomli +from rich import print + +from scripts.dependencies._dependency_bounds_upper_impl import ( + VersionCatalog, + _apply_package_replacements, + _collect_dev_pin_replacements, + _load_lock_versions, +) +from scripts.task_runner import discover_projects + + +@dataclass(frozen=True) +class WorkspaceProject: + """Workspace project metadata used for dev dependency pin refresh.""" + + name: str + project_path: str + pyproject_path: str + pyproject_file: Path + + +def _read_project_name(pyproject_file: Path) -> str: + """Return the normalized project name declared in a pyproject file.""" + with pyproject_file.open("rb") as f: + data = tomli.load(f) + + project = data.get("project", {}) or {} + project_name = str(project.get("name", "")).strip() + return project_name or pyproject_file.parent.name + + +def _discover_workspace_projects(workspace_root: Path) -> list[WorkspaceProject]: + """Return the root project plus all package projects in the workspace.""" + workspace_pyproject = workspace_root / "pyproject.toml" + projects = [ + WorkspaceProject( + name=_read_project_name(workspace_pyproject), + project_path=".", + pyproject_path="pyproject.toml", + pyproject_file=workspace_pyproject, + ) + ] + + # The root project carries the repo-wide dev toolchain pins, while package pyprojects may + # carry package-specific dev extras/groups. Refresh both surfaces in one pass so the + # workspace stays internally consistent after a tooling bump. + # Reuse the shared workspace discovery logic so this script stays aligned with the rest + # of the repo-level task runners when packages are added or moved. + for project in sorted(discover_projects(workspace_pyproject), key=lambda value: str(value)): + pyproject_file = workspace_root / project / "pyproject.toml" + if not pyproject_file.exists(): + continue + + projects.append( + WorkspaceProject( + name=_read_project_name(pyproject_file), + project_path=str(project), + pyproject_path=str(project / "pyproject.toml"), + pyproject_file=pyproject_file, + ) + ) + + return projects + + +def _normalize_filter(value: str) -> str: + """Normalize a package filter for matching project names and paths.""" + normalized = value.strip().strip("/").lower() + return normalized or "." + + +def _select_projects(projects: list[WorkspaceProject], package_filters: list[str] | None) -> list[WorkspaceProject]: + """Filter workspace projects by package name or workspace path if requested.""" + if not package_filters: + return projects + + normalized_filters = {_normalize_filter(value) for value in package_filters if value.strip()} + selected: list[WorkspaceProject] = [] + for project in projects: + normalized_path = _normalize_filter(project.project_path) + candidates = {project.name.lower(), normalized_path} + if normalized_path != ".": + candidates.add(f"./{normalized_path}") + + if candidates & normalized_filters: + selected.append(project) + + return selected + + +def main() -> None: + """Refresh exact dev dependency pins in workspace pyproject files.""" + parser = argparse.ArgumentParser( + description=( + "Refresh dev dependency pins across the workspace pyproject.toml files. " + "By default, resolves versions from PyPI and falls back to uv.lock when network access is unavailable." + ) + ) + parser.add_argument( + "--packages", + nargs="*", + default=None, + help="Optional project filters by workspace path (for example packages/core) or package name.", + ) + parser.add_argument( + "--version-source", + choices=["pypi", "lock"], + default="pypi", + help="Version source for selecting the newest dev pin.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned replacements without updating files.", + ) + args = parser.parse_args() + + workspace_root = Path(__file__).resolve().parents[2] + lock_versions = _load_lock_versions(workspace_root) + # Reuse the same version catalog as the bound-expansion tooling so dev pin refreshes choose + # versions with the same PyPI-vs-lock fallback behavior as the dependency validators. + catalog = VersionCatalog(lock_versions=lock_versions, source=args.version_source) + + selected_projects = _select_projects( + _discover_workspace_projects(workspace_root), + package_filters=args.packages, + ) + if not selected_projects: + filters = ", ".join(args.packages or []) + raise SystemExit(f"No matching workspace projects found for: {filters}") + + updated_projects = 0 + updated_requirements = 0 + for project in selected_projects: + # Keep the replacement logic centralized in the upper-bound helper so exact dev pins are + # formatted consistently regardless of whether we update them directly here or while + # widening runtime dependency bounds. + replacements = _collect_dev_pin_replacements(project.pyproject_file, catalog=catalog) + if not replacements: + continue + + updated_projects += 1 + updated_requirements += len(replacements) + if args.dry_run: + print(f"[yellow]Planned updates for {project.pyproject_path}[/yellow]") + for original, replacement in replacements.items(): + print(f" - {original} -> {replacement}") + continue + + _apply_package_replacements(project.pyproject_file, replacements) + print( + f"[green]Updated {project.pyproject_path}[/green] " + f"({project.name}) with {len(replacements)} dev dependency pin refresh(es)." + ) + + if updated_projects == 0: + print("[green]No dev dependency pin updates were needed.[/green]") + return + + action = "Would update" if args.dry_run else "Updated" + print( + f"[green]{action} {updated_requirements} dev dependency pin(s) " + f"across {updated_projects} workspace project(s).[/green]" + ) + + +if __name__ == "__main__": + main() diff --git a/python/scripts/dependencies/validate_dependency_bounds.py b/python/scripts/dependencies/validate_dependency_bounds.py new file mode 100644 index 0000000000..8563cb36da --- /dev/null +++ b/python/scripts/dependencies/validate_dependency_bounds.py @@ -0,0 +1,490 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: INP001, S404, S603 + +"""Unified dependency-bound validation entrypoint. + +Modes: +- test: run workspace-wide compatibility gates at lower and upper resolutions. +- lower: run lower-bound expansion for one package. +- upper: run upper-bound expansion for one package. +- both: run lower then upper expansion for one package. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +import tomli +from rich import print +from scripts.dependencies._dependency_bounds_runtime import ( + extend_command_with_runtime_tools, + extend_command_with_task, +) +from scripts.dependencies._dependency_bounds_upper_impl import ( + _build_internal_graph, + _build_workspace_package_map, + _load_package_name, + _resolve_internal_editables, +) +from scripts.task_runner import discover_projects, extract_poe_tasks + +_LOWER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_lower_impl" +_UPPER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_upper_impl" + + +@dataclass +class PackageTestPlan: + """Workspace package settings needed for global test-mode validation.""" + + project_path: Path + package_name: str + include_dev_group: bool + include_dev_extra: bool + optional_extras: list[str] + internal_editables: list[Path] + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _truncate_error(stdout: str, stderr: str, *, max_chars: int = 2000) -> str: + combined = "\n".join(part for part in [stderr.strip(), stdout.strip()] if part) + if len(combined) <= max_chars: + return combined + return f"...\n{combined[-max_chars:]}" + + +def _write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False)) + + +def _coerce_subprocess_output(output: str | bytes | None) -> str: + if output is None: + return "" + if isinstance(output, bytes): + return output.decode(errors="replace") + return output + + +def _build_test_plans(workspace_root: Path, package_filter: str | None) -> list[PackageTestPlan]: + workspace_pyproject = workspace_root / "pyproject.toml" + package_map = _build_workspace_package_map(workspace_root) + internal_graph = _build_internal_graph(workspace_root, package_map) + normalized_filter = None if package_filter in {None, "", "*"} else package_filter + + plans: list[PackageTestPlan] = [] + missing_tasks: list[str] = [] + for project_path in sorted(set(discover_projects(workspace_pyproject))): + pyproject_file = workspace_root / project_path / "pyproject.toml" + if not pyproject_file.exists(): + continue + + package_name = _load_package_name(pyproject_file) + if normalized_filter and str(project_path) != normalized_filter and package_name != normalized_filter: + continue + + available_tasks = extract_poe_tasks(pyproject_file) + required_tasks = {"test", "pyright"} + if not required_tasks.issubset(available_tasks): + missing = sorted(required_tasks - available_tasks) + missing_tasks.append(f"{project_path}: missing {', '.join(missing)}") + continue + with pyproject_file.open("rb") as f: + package_config = tomli.load(f) + project_section = package_config.get("project", {}) + optional_dependencies = project_section.get("optional-dependencies", {}) or {} + dependency_groups = package_config.get("dependency-groups", {}) or {} + + plans.append( + PackageTestPlan( + project_path=project_path, + package_name=package_name, + include_dev_group="dev" in dependency_groups, + include_dev_extra="dev" in optional_dependencies, + optional_extras=sorted(name for name in optional_dependencies if name not in {"all", "dev"}), + internal_editables=_resolve_internal_editables(package_name, package_map, internal_graph), + ) + ) + + if missing_tasks: + details = "\n".join(missing_tasks) + raise RuntimeError(f"Test mode requires test+pyright in every package.\n{details}") + return plans + + +def _run_package_tasks( + workspace_root: Path, + plan: PackageTestPlan, + *, + resolution: str, + timeout_seconds: int, + dry_run: bool, +) -> tuple[bool, str | None]: + # Test mode intentionally uses the same isolated uv execution model as the optimizer scripts + # so the smoke gate matches the environment that lower/upper probes will run in. + env = dict(os.environ) + env["UV_PRERELEASE"] = "allow" + # Avoid letting nested uv commands target the caller's active environment; validation should + # stay inside uv's isolated throwaway environment instead of mutating `.venv`. + env.pop("VIRTUAL_ENV", None) + + for task_name in ("test", "pyright"): + command = [ + "uv", + "--no-progress", + "--directory", + str(workspace_root / plan.project_path), + "run", + "--isolated", + "--resolution", + resolution, + "--prerelease", + "allow", + "--quiet", + ] + extend_command_with_runtime_tools(command, workspace_root) + if plan.include_dev_group: + command.extend(["--group", "dev"]) + if plan.include_dev_extra: + command.extend(["--extra", "dev"]) + for extra_name in plan.optional_extras: + command.extend(["--extra", extra_name]) + for editable_path in plan.internal_editables: + command.extend(["--with-editable", str(editable_path)]) + extend_command_with_task(command, task_name) + + if dry_run: + print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}") + continue + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + env=env, + ) + except subprocess.TimeoutExpired as exc: + error_message = _truncate_error( + _coerce_subprocess_output(exc.stdout), + _coerce_subprocess_output(exc.stderr), + ) + if not error_message: + error_message = "Process timed out without additional output." + return ( + False, + ( + f"Task '{task_name}' timed out for {plan.project_path} at resolution '{resolution}' " + f"after {timeout_seconds} seconds.\n{error_message}" + ), + ) + if result.returncode != 0: + error_message = _truncate_error(result.stdout, result.stderr) + return ( + False, + f"Task '{task_name}' failed for {plan.project_path} at resolution '{resolution}'.\n{error_message}", + ) + return True, None + + +def _run_test_mode( + *, + workspace_root: Path, + package_filter: str | None, + timeout_seconds: int, + dry_run: bool, + output_json: Path, +) -> int: + plans = _build_test_plans(workspace_root, package_filter) + if not plans: + print("[yellow]No workspace packages found for test mode.[/yellow]") + return 0 + + report: dict = { + "started_at": _utc_now(), + "mode": "test", + "workspace_root": str(workspace_root), + "dry_run": dry_run, + "scenarios": [], + "summary": { + "packages_total": len(plans), + "scenarios_passed": 0, + "scenarios_failed": 0, + }, + } + _write_json(output_json, report) + print(f"[cyan]Writing dependency-bounds test report to {output_json}[/cyan]") + + # Smoke both ends of the allowed range: `lowest-direct` approximates lower-bound resolution, + # while `highest` exercises the newest versions currently permitted by each package's specifiers. + scenario_specs = [("lower", "lowest-direct"), ("upper", "highest")] + for scenario_name, resolution in scenario_specs: + print(f"[bold]Running {scenario_name} scenario ({resolution})[/bold]") + scenario_result: dict = { + "name": scenario_name, + "resolution": resolution, + "status": "passed", + "packages": [], + } + for plan in plans: + success, error = _run_package_tasks( + workspace_root, + plan, + resolution=resolution, + timeout_seconds=timeout_seconds, + dry_run=dry_run, + ) + scenario_result["packages"].append( + { + "project_path": str(plan.project_path), + "package_name": plan.package_name, + "status": "passed" if success else "failed", + "error": error, + } + ) + if success: + print(f"[green]{plan.project_path}: {scenario_name} passed[/green]") + continue + + scenario_result["status"] = "failed" + report["scenarios"].append(scenario_result) + report["summary"]["scenarios_failed"] += 1 + report["updated_at"] = _utc_now() + _write_json(output_json, report) + print(f"[red]{plan.project_path}: {scenario_name} failed[/red]") + print(f"[red]{error}[/red]") + return 1 + + report["scenarios"].append(scenario_result) + report["summary"]["scenarios_passed"] += 1 + report["updated_at"] = _utc_now() + _write_json(output_json, report) + + print("[bold green]Test mode completed successfully.[/bold green]") + return 0 + + +def _build_optimizer_command( + *, + workspace_root: Path, + module_name: str, + package: str | None, + dependencies: list[str] | None, + parallelism: int, + max_candidates: int, + version_source: str, + timeout_seconds: int, + dry_run: bool, + output_json: str | None, +) -> list[str]: + command = [ + sys.executable, + "-m", + module_name, + "--parallelism", + str(parallelism), + "--max-candidates", + str(max_candidates), + "--version-source", + version_source, + "--timeout-seconds", + str(timeout_seconds), + ] + if package: + command.extend(["--packages", package]) + if dependencies: + command.extend(["--dependencies", *dependencies]) + if output_json: + command.extend(["--output-json", output_json]) + if dry_run: + command.append("--dry-run") + return command + + +def _run_optimizer_mode( + *, + workspace_root: Path, + module_name: str, + package: str | None, + dependencies: list[str] | None, + parallelism: int, + max_candidates: int, + version_source: str, + timeout_seconds: int, + dry_run: bool, + output_json: str | None, +) -> int: + command = _build_optimizer_command( + workspace_root=workspace_root, + module_name=module_name, + package=package, + dependencies=dependencies, + parallelism=parallelism, + max_candidates=max_candidates, + version_source=version_source, + timeout_seconds=timeout_seconds, + dry_run=dry_run, + output_json=output_json, + ) + print(f"[cyan]Running:[/cyan] {' '.join(command)}") + result = subprocess.run(command, cwd=workspace_root, check=False) + return result.returncode + + +def _with_suffix(path: str | None, suffix: str) -> str | None: + if path is None: + return None + value = Path(path) + return str(value.with_name(f"{value.stem}-{suffix}{value.suffix}")) + + +def main() -> None: + """Parse arguments and run the requested dependency-bound mode.""" + parser = argparse.ArgumentParser( + description=( + "Unified dependency-bound workflow. Use mode=test for workspace-wide lower+upper gates, " + "or lower/upper/both for package-scoped or workspace-wide bound expansion." + ) + ) + parser.add_argument( + "--mode", + required=True, + choices=("test", "lower", "upper", "both"), + help="Execution mode: test (global) or lower/upper/both (package-scoped).", + ) + parser.add_argument( + "--package", + default=None, + help="Optional workspace package path/name filter for all modes. Use '*' or omit it for the whole workspace.", + ) + parser.add_argument( + "--dependencies", + nargs="*", + default=None, + help="Optional dependency-name filters for lower/upper/both. Omit to process all matching dependencies.", + ) + parser.add_argument( + "--parallelism", + type=int, + default=max(1, min(os.cpu_count() or 4, 8)), + help="Parallelism forwarded to lower/upper optimizer scripts.", + ) + parser.add_argument( + "--max-candidates", + type=int, + default=0, + help="Maximum candidate bounds per dependency for lower/upper optimizer scripts (0 = no limit).", + ) + parser.add_argument( + "--version-source", + choices=("pypi", "lock"), + default="pypi", + help="Version source for candidate bounds.", + ) + parser.add_argument( + "--timeout-seconds", + type=int, + default=1200, + help="Timeout per task command execution.", + ) + parser.add_argument("--dry-run", action="store_true", help="Do not execute mutating actions.") + parser.add_argument( + "--output-json", + default=None, + help="Optional output report path for lower/upper modes (both mode appends -lower/-upper).", + ) + parser.add_argument( + "--test-output-json", + default="scripts/dependencies/dependency-bounds-test-results.json", + help="Output report path for test mode.", + ) + args = parser.parse_args() + + workspace_root = Path(__file__).resolve().parents[2] + normalized_package = None if args.package in {None, "", "*"} else args.package + + if args.mode == "test": + exit_code = _run_test_mode( + workspace_root=workspace_root, + package_filter=normalized_package, + timeout_seconds=args.timeout_seconds, + dry_run=args.dry_run, + output_json=(workspace_root / args.test_output_json).resolve(), + ) + raise SystemExit(exit_code) + + if args.mode == "lower": + exit_code = _run_optimizer_mode( + workspace_root=workspace_root, + module_name=_LOWER_IMPL_MODULE, + package=normalized_package, + dependencies=args.dependencies, + parallelism=args.parallelism, + max_candidates=args.max_candidates, + version_source=args.version_source, + timeout_seconds=args.timeout_seconds, + dry_run=args.dry_run, + output_json=args.output_json, + ) + raise SystemExit(exit_code) + + if args.mode == "upper": + exit_code = _run_optimizer_mode( + workspace_root=workspace_root, + module_name=_UPPER_IMPL_MODULE, + package=normalized_package, + dependencies=args.dependencies, + parallelism=args.parallelism, + max_candidates=args.max_candidates, + version_source=args.version_source, + timeout_seconds=args.timeout_seconds, + dry_run=args.dry_run, + output_json=args.output_json, + ) + raise SystemExit(exit_code) + + # Lower runs first so the subsequent upper pass starts from the widest lower bound that has + # already been validated; when `--output-json` is supplied, each pass gets its own suffixed report. + lower_exit = _run_optimizer_mode( + workspace_root=workspace_root, + module_name=_LOWER_IMPL_MODULE, + package=normalized_package, + dependencies=args.dependencies, + parallelism=args.parallelism, + max_candidates=args.max_candidates, + version_source=args.version_source, + timeout_seconds=args.timeout_seconds, + dry_run=args.dry_run, + output_json=_with_suffix(args.output_json, "lower"), + ) + if lower_exit != 0: + raise SystemExit(lower_exit) + + upper_exit = _run_optimizer_mode( + workspace_root=workspace_root, + module_name=_UPPER_IMPL_MODULE, + package=normalized_package, + dependencies=args.dependencies, + parallelism=args.parallelism, + max_candidates=args.max_candidates, + version_source=args.version_source, + timeout_seconds=args.timeout_seconds, + dry_run=args.dry_run, + output_json=_with_suffix(args.output_json, "upper"), + ) + raise SystemExit(upper_exit) + + +if __name__ == "__main__": + main() diff --git a/python/uv.lock b/python/uv.lock index 448346caa6..a21d16ed54 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,19 +1,22 @@ version = 1 revision = 3 -requires-python = ">=3.11" +requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version == '3.13.*' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", - "python_full_version < '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform == 'linux'", "python_full_version == '3.13.*' and sys_platform == 'linux'", "python_full_version == '3.12.*' and sys_platform == 'linux'", - "python_full_version < '3.12' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'linux'", "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'win32'", ] supported-markers = [ "sys_platform == 'darwin'", @@ -48,16 +51,10 @@ members = [ "agent-framework-purview", "agent-framework-redis", ] -overrides = [ - { name = "grpcio", marker = "python_full_version < '3.14'", specifier = ">=1.62.3,<1.68.0" }, - { name = "grpcio", marker = "python_full_version >= '3.14'", specifier = ">=1.76.0" }, - { name = "uvicorn", specifier = "==0.38.0" }, - { name = "websockets", specifier = "==15.0.1" }, -] [[package]] name = "a2a-sdk" -version = "0.3.24" +version = "0.3.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -66,9 +63,9 @@ dependencies = [ { 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/ad/76/cefa956fb2d3911cb91552a1da8ce2dbb339f1759cb475e2982f0ae2332b/a2a_sdk-0.3.24.tar.gz", hash = "sha256:3581e6e8a854cd725808f5732f90b7978e661b6d4e227a4755a8f063a3c1599d", size = 255550, upload-time = "2026-02-20T10:05:43.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2fe24e0a85240a651006c12f79bdb37156adc760a96c44bc002ebda77916/a2a_sdk-0.3.23.tar.gz", hash = "sha256:7c46b8572c4633a2b41fced2833e11e62871e8539a5b3c782ba2ba1e33d213c2", size = 255265, upload-time = "2026-02-17T08:34:34.648Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/6e/cae5f0caea527b39c0abd7204d9416768764573c76649ca03cc345a372be/a2a_sdk-0.3.24-py3-none-any.whl", hash = "sha256:7b248767096bb55311f57deebf6b767349388d94c1b376c60cb8f6b715e053f6", size = 145752, upload-time = "2026-02-20T10:05:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/d4/20/77d119f19ab03449d3e6bc0b1f11296d593dae99775c1d891ab1e290e416/a2a_sdk-0.3.23-py3-none-any.whl", hash = "sha256:8c2f01dffbfdd3509eafc15c4684743e6ae75e69a5df5d6f87be214c948e7530", size = 145689, upload-time = "2026-02-17T08:34:33.263Z" }, ] [[package]] @@ -124,21 +121,21 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ - { name = "flit", specifier = ">=3.12.0" }, - { name = "mypy", specifier = ">=1.16.1" }, - { name = "poethepoet", specifier = ">=0.36.0" }, - { name = "prek", specifier = ">=0.3.2" }, - { name = "pyright", specifier = ">=1.1.402" }, - { name = "pytest", specifier = ">=8.4.1" }, - { name = "pytest-asyncio", specifier = ">=1.0.0" }, - { name = "pytest-cov", specifier = ">=6.2.1" }, - { name = "pytest-retry", specifier = ">=1" }, - { name = "pytest-timeout", specifier = ">=2.3.1" }, - { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" }, - { name = "rich" }, - { name = "ruff", specifier = ">=0.11.8" }, - { name = "tomli" }, - { name = "uv", specifier = ">=0.9,<1.0.0" }, + { name = "flit", specifier = "==3.12.0" }, + { name = "mypy", specifier = "==1.19.1" }, + { name = "poethepoet", specifier = "==0.42.1" }, + { name = "prek", specifier = "==0.3.4" }, + { name = "pyright", specifier = "==1.1.408" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==1.3.0" }, + { name = "pytest-cov", specifier = "==7.0.0" }, + { name = "pytest-retry", specifier = "==1.7.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" }, + { name = "rich", specifier = "==13.7.1" }, + { name = "ruff", specifier = "==0.15.5" }, + { name = "tomli", specifier = "==2.4.0" }, + { name = "uv", specifier = "==0.10.9" }, ] [[package]] @@ -152,7 +149,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.5" }, + { name = "a2a-sdk", specifier = ">=0.3.5,<0.3.24" }, { name = "agent-framework-core", editable = "packages/core" }, ] @@ -164,7 +161,7 @@ dependencies = [ { 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", 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] @@ -175,12 +172,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ag-ui-protocol", specifier = ">=0.1.9" }, + { name = "ag-ui-protocol", specifier = "==0.1.13" }, { name = "agent-framework-core", editable = "packages/core" }, - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, - { name = "uvicorn", specifier = ">=0.30.0" }, + { name = "fastapi", specifier = ">=0.115.0,<0.133.1" }, + { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.42.0" }, ] provides-extras = ["dev"] @@ -196,7 +193,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "anthropic", specifier = ">=0.70.0,<1" }, + { name = "anthropic", specifier = ">=0.80.0,<0.80.1" }, ] [[package]] @@ -213,9 +210,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "aiohttp" }, - { name = "azure-ai-agents", specifier = "==1.2.0b5" }, - { name = "azure-ai-inference", specifier = ">=1.0.0b9" }, + { name = "aiohttp", specifier = ">=3.7.0,<4" }, + { name = "azure-ai-agents", specifier = ">=1.2.0b5,<1.2.0b6" }, + { name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" }, ] [[package]] @@ -230,7 +227,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-search-documents", specifier = "==11.7.0b2" }, + { name = "azure-search-documents", specifier = ">=11.7.0b2,<11.7.0b3" }, ] [[package]] @@ -245,7 +242,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-cosmos", specifier = ">=4.9.0" }, + { name = "azure-cosmos", specifier = ">=4.3.0,<5" }, ] [[package]] @@ -263,8 +260,8 @@ dependencies = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-durabletask", editable = "packages/durabletask" }, - { name = "azure-functions" }, - { name = "azure-functions-durable" }, + { name = "azure-functions", specifier = ">=1.24.0,<2" }, + { name = "azure-functions-durable", specifier = ">=1.3.1,<2" }, ] [package.metadata.requires-dev] @@ -299,7 +296,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "openai-chatkit", specifier = ">=1.4.0,<2.0.0" }, + { name = "openai-chatkit", specifier = ">=1.4.1,<2.0.0" }, ] [[package]] @@ -314,7 +311,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "claude-agent-sdk", specifier = ">=0.1.25" }, + { name = "claude-agent-sdk", specifier = ">=0.1.36,<0.1.49" }, ] [[package]] @@ -329,7 +326,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "microsoft-agents-copilotstudio-client", specifier = ">=0.3.1" }, + { name = "microsoft-agents-copilotstudio-client", specifier = ">=0.3.1,<0.3.2" }, ] [[package]] @@ -366,7 +363,7 @@ all = [ { 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-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-github-copilot", 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-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'" }, @@ -401,14 +398,14 @@ requires-dist = [ { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, { name = "azure-identity", specifier = ">=1,<2" }, { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, - { name = "openai", specifier = ">=1.99.0" }, - { name = "opentelemetry-api", specifier = ">=1.39.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, - { name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13" }, - { name = "packaging", specifier = ">=24.1" }, + { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "opentelemetry-api", specifier = ">=1.39.0,<2" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, + { name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13,<0.4.14" }, + { name = "packaging", specifier = ">=24.1,<25" }, { name = "pydantic", specifier = ">=2,<3" }, { name = "python-dotenv", specifier = ">=1,<2" }, - { name = "typing-extensions" }, + { name = "typing-extensions", specifier = ">=4.15.0,<5" }, ] provides-extras = ["all"] @@ -430,12 +427,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "powerfx", marker = "python_full_version < '3.14'", specifier = ">=0.0.31" }, + { name = "powerfx", marker = "python_full_version < '3.14'", specifier = ">=0.0.32,<0.0.35" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "types-pyyaml" }] +dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" @@ -444,8 +441,7 @@ source = { editable = "packages/devui" } dependencies = [ { 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 = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", 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] @@ -463,13 +459,12 @@ dev = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" }, - { name = "fastapi", specifier = ">=0.104.0" }, - { name = "pytest", marker = "extra == 'all'", specifier = ">=7.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, - { name = "watchdog", marker = "extra == 'all'", specifier = ">=3.0.0" }, - { name = "watchdog", marker = "extra == 'dev'", specifier = ">=3.0.0" }, + { name = "fastapi", specifier = ">=0.115.0,<0.133.1" }, + { name = "pytest", marker = "extra == 'all'", specifier = "==9.0.2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.42.0" }, + { name = "watchdog", marker = "extra == 'all'", specifier = "==6.0.0" }, + { name = "watchdog", marker = "extra == 'dev'", specifier = "==6.0.0" }, ] provides-extras = ["dev", "all"] @@ -492,13 +487,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "durabletask", specifier = ">=1.3.0" }, - { name = "durabletask-azuremanaged", specifier = ">=1.3.0" }, - { name = "python-dateutil", specifier = ">=2.8.0" }, + { name = "durabletask", specifier = ">=1.3.0,<2" }, + { name = "durabletask-azuremanaged", specifier = ">=1.3.0,<2" }, + { name = "python-dateutil", specifier = ">=2.8.0,<3" }, ] [package.metadata.requires-dev] -dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }] +dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260305" }] [[package]] name = "agent-framework-foundry-local" @@ -512,7 +507,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "foundry-local-sdk", specifier = ">=0.5.1,<1" }, + { name = "foundry-local-sdk", specifier = ">=0.5.1,<0.5.2" }, ] [[package]] @@ -521,13 +516,13 @@ version = "1.0.0b260311" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "github-copilot-sdk", 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] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", specifier = ">=0.1.32" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.31,<0.1.33" }, ] [[package]] @@ -555,7 +550,8 @@ math = [ ] tau2 = [ { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", 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.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 = "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'" }, ] @@ -583,8 +579,8 @@ requires-dist = [ { name = "loguru", marker = "extra == 'tau2'", specifier = ">=0.7.3" }, { name = "numpy", marker = "extra == 'tau2'" }, { name = "opentelemetry-api", marker = "extra == 'gaia'", specifier = ">=1.39.0" }, - { name = "orjson", marker = "extra == 'gaia'", specifier = ">=3.8.0" }, - { name = "pyarrow", marker = "extra == 'gaia'", specifier = ">=10.0.0" }, + { name = "orjson", marker = "extra == 'gaia'", specifier = ">=3.10.7,<4" }, + { name = "pyarrow", marker = "extra == 'gaia'", specifier = ">=18.0.0" }, { name = "pydantic", marker = "extra == 'gaia'", specifier = ">=2.0.0" }, { name = "pydantic", marker = "extra == 'tau2'", specifier = ">=2.0.0" }, { name = "sympy", marker = "extra == 'math'", specifier = ">=1.13.0" }, @@ -595,17 +591,17 @@ provides-extras = ["gaia", "lightning", "tau2", "math"] [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=1.16.1" }, - { name = "poethepoet", specifier = ">=0.36.0" }, - { name = "prek", specifier = ">=0.3.2" }, - { name = "pyright", specifier = ">=1.1.402" }, - { name = "pytest", specifier = ">=8.4.1" }, - { name = "rich" }, - { name = "ruff", specifier = ">=0.11.8" }, + { name = "mypy", specifier = "==1.19.1" }, + { name = "poethepoet", specifier = "==0.42.1" }, + { name = "prek", specifier = "==0.3.4" }, + { name = "pyright", specifier = "==1.1.408" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "rich", specifier = "==13.7.1" }, + { name = "ruff", specifier = "==0.15.5" }, { name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" }, - { name = "tomli" }, - { name = "tomli-w" }, - { name = "uv" }, + { name = "tomli", specifier = "==2.4.0" }, + { name = "tomli-w", specifier = "==1.2.0" }, + { name = "uv", specifier = "==0.10.9" }, ] [[package]] @@ -620,7 +616,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "mem0ai", specifier = ">=1.0.0" }, + { name = "mem0ai", specifier = ">=1.0.0,<2" }, ] [[package]] @@ -635,7 +631,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "ollama", specifier = ">=0.5.3" }, + { name = "ollama", specifier = ">=0.5.3,<0.5.4" }, ] [[package]] @@ -662,8 +658,8 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-core", specifier = ">=1.30.0" }, - { name = "httpx", specifier = ">=0.27.0" }, + { name = "azure-core", specifier = ">=1.30.0,<2" }, + { name = "httpx", specifier = ">=0.27.0,<0.29" }, ] [[package]] @@ -672,7 +668,8 @@ version = "1.0.0b260311" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", 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.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 = "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'" }, ] @@ -680,9 +677,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "numpy", specifier = ">=2.2.6" }, - { name = "redis", specifier = ">=6.4.0" }, - { name = "redisvl", specifier = ">=0.8.2" }, + { name = "numpy", specifier = ">=2.2.6,<3" }, + { name = "redis", specifier = ">=6.4.0,<7.2.1" }, + { name = "redisvl", specifier = ">=0.11.0,<0.16" }, ] [[package]] @@ -753,6 +750,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { 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'" }, @@ -761,6 +759,23 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, @@ -881,7 +896,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.84.0" +version = "0.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -893,9 +908,9 @@ dependencies = [ { 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/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/63/791e14ef5a8ecb485cef5b5d058c7ca3ad6c50a2f94cf4cea5231c6b7c16/anthropic-0.80.0.tar.gz", hash = "sha256:ef042586673fdcab2a6ffd381aa5f9a1bcce38ffe73c07fe70bd56d12b8124ba", size = 533291, upload-time = "2026-02-17T19:26:26.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4b/665f29338f51d0c2f9e04b276ea54cc1e957ae5c521a0ad868aa80abc608/anthropic-0.80.0-py3-none-any.whl", hash = "sha256:dad0e40ec371ee686e9ffb2e0cb461a0ed51447fa100927fb5d39b174c286d6f", size = 453667, upload-time = "2026-02-17T19:26:29.96Z" }, ] [[package]] @@ -903,6 +918,7 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { 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')" }, ] @@ -1124,6 +1140,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -1135,30 +1160,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.40.76" +version = "1.42.66" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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/26/04/8cf6cf7e6390c71b9c958f3bfedc45d1182b51a35f7789354bf7b2ff4e8c/boto3-1.40.76.tar.gz", hash = "sha256:16f4cf97f8dd8e0aae015f4dc66219bd7716a91a40d1e2daa0dafa241a4761c5", size = 111598, upload-time = "2025-11-18T20:23:10.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/2e/67206daa5acb6053157ae5241421713a84ed6015d33d0781985bd5558898/boto3-1.42.66.tar.gz", hash = "sha256:3bec5300fb2429c3be8e8961fdb1f11e85195922c8a980022332c20af05616d5", size = 112805, upload-time = "2026-03-11T19:58:19.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/8e/966263696eb441e8d1c4daa5fdfb3b4be10a96a23c418cc74c80b0b03d4e/boto3-1.40.76-py3-none-any.whl", hash = "sha256:8df6df755727be40ad9e309cfda07f9a12c147e17b639430c55d4e4feee8a167", size = 139359, upload-time = "2025-11-18T20:23:08.75Z" }, + { url = "https://files.pythonhosted.org/packages/4c/09/83224363c3f5e468e298e48beb577ffe8cb51f18c2116bc1ecf404796e60/boto3-1.42.66-py3-none-any.whl", hash = "sha256:7c6c60dc5500e8a2967a306372a5fdb4c7f9a5b8adc5eb9aa2ebb5081c51ff47", size = 140557, upload-time = "2026-03-11T19:58:17.61Z" }, ] [[package]] name = "botocore" -version = "1.40.76" +version = "1.42.66" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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/07/eb/50e2d280589a3c20c3b649bb66262d2b53a25c03262e4cc492048ac7540a/botocore-1.40.76.tar.gz", hash = "sha256:2b16024d68b29b973005adfb5039adfe9099ebe772d40a90ca89f2e165c495dc", size = 14494001, upload-time = "2025-11-18T20:22:59.131Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/ef/1c8f89da69b0c3742120e19a6ea72ec46ac0596294466924fdd4cf0f36bb/botocore-1.42.66.tar.gz", hash = "sha256:39756a21142b646de552d798dde2105759b0b8fa0d881a34c26d15bd4c9448fa", size = 14977446, upload-time = "2026-03-11T19:58:07.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/6c/522e05388aa6fc66cf8ea46c6b29809a1a6f527ea864998b01ffb368ca36/botocore-1.40.76-py3-none-any.whl", hash = "sha256:fe425d386e48ac64c81cbb4a7181688d813df2e2b4c78b95ebe833c9e868c6f4", size = 14161738, upload-time = "2025-11-18T20:22:55.332Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/7b45ed2ca300c1ad38ecfc82c1368546d4a90512d9dff589ebbd182a7317/botocore-1.42.66-py3-none-any.whl", hash = "sha256:ac48af1ab527dfa08c4617c387413ca56a7f87780d7bfc1da34ef847a59219a5", size = 14653886, upload-time = "2026-03-11T19:58:04.922Z" }, ] [[package]] @@ -1179,6 +1204,18 @@ dependencies = [ ] 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 = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, @@ -1246,6 +1283,22 @@ version = "3.4.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, @@ -1320,6 +1373,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", 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/6c/dd/2818538efd18ed4ef72d4775efa75bb36cbea0fa418eda51df85ee9c2424/claude_agent_sdk-0.1.48.tar.gz", hash = "sha256:ee294d3f02936c0b826119ffbefcf88c67731cf8c2d2cb7111ccc97f76344272", size = 87375, upload-time = "2026-03-07T00:21:37.087Z" } wheels = [ @@ -1362,12 +1416,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] dependencies = [ - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.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/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1450,6 +1590,20 @@ version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, @@ -1567,6 +1721,7 @@ version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ @@ -1683,8 +1838,7 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, 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 = "grpcio", version = "1.78.0", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, ] @@ -1719,6 +1873,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -1730,7 +1896,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.133.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1739,9 +1905,9 @@ dependencies = [ { 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/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/04/ab382c7c03dd545f2c964d06e87ad0d5faa944a2434186ad9c285f5d87e0/fastapi-0.133.0.tar.gz", hash = "sha256:b900a2bf5685cdb0647a41d5900bdeafc3a9e8a28ac08c6246b76699e164d60d", size = 373265, upload-time = "2026-02-24T09:53:40.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787, upload-time = "2026-02-24T09:53:41.404Z" }, ] [[package]] @@ -1765,6 +1931,17 @@ version = "0.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/b2/731a6696e37cd20eed353f69a09f37a984a43c9713764ee3f7ad5f57f7f9/fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a", size = 516760, upload-time = "2025-10-19T22:25:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/c5/79/c73c47be2a3b8734d16e628982653517f80bbe0570e27185d91af6096507/fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00", size = 264748, upload-time = "2025-10-19T22:41:52.873Z" }, + { url = "https://files.pythonhosted.org/packages/24/c5/84c1eea05977c8ba5173555b0133e3558dc628bcf868d6bf1689ff14aedc/fastuuid-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470", size = 254537, upload-time = "2025-10-19T22:33:55.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/4e362367b7fa17dbed646922f216b9921efb486e7abe02147e4b917359f8/fastuuid-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d", size = 278994, upload-time = "2025-10-19T22:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/3985be633b5a428e9eaec4287ed4b873b7c4c53a9639a8b416637223c4cd/fastuuid-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8", size = 280003, upload-time = "2025-10-19T22:23:45.415Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/6ef192a6df34e2266d5c9deb39cd3eea986df650cbcfeaf171aa52a059c3/fastuuid-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219", size = 303583, upload-time = "2025-10-19T22:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/9d/11/8a2ea753c68d4fece29d5d7c6f3f903948cc6e82d1823bc9f7f7c0355db3/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6", size = 460955, upload-time = "2025-10-19T22:36:25.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/42/7a32c93b6ce12642d9a152ee4753a078f372c9ebb893bc489d838dd4afd5/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe", size = 480763, upload-time = "2025-10-19T22:24:28.451Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e9/a5f6f686b46e3ed4ed3b93770111c233baac87dd6586a411b4988018ef1d/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d", size = 452613, upload-time = "2025-10-19T22:25:06.827Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c9/18abc73c9c5b7fc0e476c1733b678783b2e8a35b0be9babd423571d44e98/fastuuid-0.14.0-cp310-cp310-win32.whl", hash = "sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a", size = 155045, upload-time = "2025-10-19T22:28:32.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8a/d9e33f4eb4d4f6d9f2c5c7d7e96b5cdbb535c93f3b1ad6acce97ee9d4bf8/fastuuid-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4", size = 156122, upload-time = "2025-10-19T22:23:15.59Z" }, { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, @@ -1813,11 +1990,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.0" +version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] @@ -1864,51 +2041,59 @@ wheels = [ [[package]] name = "fonttools" -version = "4.61.1" +version = "4.62.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/9db48ec7f6b95bae7b20667ded54f18dba8e759ef66232c8683822ae26fc/fonttools-4.62.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:62b6a3d0028e458e9b59501cf7124a84cd69681c433570e4861aff4fb54a236c", size = 2873527, upload-time = "2026-03-09T16:48:12.416Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/86eccfdc922cb9fafc63189a9793fa9f6dd60e68a07be42e454ef2c0deae/fonttools-4.62.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:966557078b55e697f65300b18025c54e872d7908d1899b7314d7c16e64868cb2", size = 2417427, upload-time = "2026-03-09T16:48:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/d3/98/f547a1fceeae81a9a5c6461bde2badac8bf50bda7122a8012b32b1e65396/fonttools-4.62.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf34861145b516cddd19b07ae6f4a61ea1c6326031b960ec9ddce8ee815e888", size = 4934993, upload-time = "2026-03-09T16:48:18.186Z" }, + { url = "https://files.pythonhosted.org/packages/5c/57/a23a051fcff998fdfabdd33c6721b5bad499da08b586d3676993410071f0/fonttools-4.62.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e2ff573de2775508c8a366351fb901c4ced5dc6cf2d87dd15c973bedcdd5216", size = 4892154, upload-time = "2026-03-09T16:48:20.736Z" }, + { url = "https://files.pythonhosted.org/packages/e2/62/e27644b433dc6db1d47bc6028a27d772eec5cc8338e24a9a1fce5d7120aa/fonttools-4.62.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:55b189a1b3033860a38e4e5bd0626c5aa25c7ce9caee7bc784a8caec7a675401", size = 4911635, upload-time = "2026-03-09T16:48:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e2/1bf141911a5616bacfe9cf237c80ccd69d0d92482c38c0f7f6a55d063ad9/fonttools-4.62.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:825f98cd14907c74a4d0a3f7db8570886ffce9c6369fed1385020febf919abf6", size = 5031492, upload-time = "2026-03-09T16:48:25.095Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/790c292f4347ecfa77d9c7e0d1d91e04ab227f6e4a337ed4fe37ca388048/fonttools-4.62.0-cp310-cp310-win32.whl", hash = "sha256:c858030560f92a054444c6e46745227bfd3bb4e55383c80d79462cd47289e4b5", size = 1507656, upload-time = "2026-03-09T16:48:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/08c0b7f8bac6e44638de6fe9a3e710a623932f60eccd58912c4d4743516d/fonttools-4.62.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bf75eb69330e34ad2a096fac67887102c8537991eb6cac1507fc835bbb70e0a", size = 1556540, upload-time = "2026-03-09T16:48:30.359Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/63d79ca41020dd460b51f1e0f58ad1ff0a36b7bcbdf8f3971d52836581e9/fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9", size = 2870816, upload-time = "2026-03-09T16:48:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7a/9aeec114bc9fc00d757a41f092f7107863d372e684a5b5724c043654477c/fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13", size = 2416127, upload-time = "2026-03-09T16:48:34.627Z" }, + { url = "https://files.pythonhosted.org/packages/5a/71/12cfd8ae0478b7158ffa8850786781f67e73c00fd897ef9d053415c5f88b/fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5", size = 5100678, upload-time = "2026-03-09T16:48:36.454Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d7/8e4845993ee233c2023d11babe9b3dae7d30333da1d792eeccebcb77baab/fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff", size = 5070859, upload-time = "2026-03-09T16:48:38.786Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a0/287ae04cd883a52e7bb1d92dfc4997dcffb54173761c751106845fa9e316/fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9", size = 5076689, upload-time = "2026-03-09T16:48:41.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4e/a2377ad26c36fcd3e671a1c316ea5ed83107de1588e2d897a98349363bc7/fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582", size = 5202053, upload-time = "2026-03-09T16:48:43.867Z" }, + { url = "https://files.pythonhosted.org/packages/44/2e/ad0472e69b02f83dc88983a9910d122178461606404be5b4838af6d1744a/fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1", size = 2292852, upload-time = "2026-03-09T16:48:46.962Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/f5a4c42c117f8113ce04048053c128d17426751a508f26398110c993a074/fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed", size = 2344367, upload-time = "2026-03-09T16:48:48.818Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/7ad1ffc080619f67d0b1e0fa6a0578f0be077404f13fd8e448d1616a94a3/fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221", size = 2870004, upload-time = "2026-03-09T16:48:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8b/ba59069a490f61b737e064c3129453dbd28ee38e81d56af0d04d7e6b4de4/fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85", size = 2414662, upload-time = "2026-03-09T16:48:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8c/c52a4310de58deeac7e9ea800892aec09b00bb3eb0c53265b31ec02be115/fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72", size = 5032975, upload-time = "2026-03-09T16:48:55.718Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a1/d16318232964d786907b9b3613b8409f74cf0be2da400854509d3a864e43/fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e", size = 4988544, upload-time = "2026-03-09T16:48:57.715Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8d/7e745ca3e65852adc5e52a83dc213fe1b07d61cb5b394970fcd4b1199d1e/fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51", size = 4971296, upload-time = "2026-03-09T16:48:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4b/92cfcba4bf8373f51c49c5ae4b512ead6fbda7d61a0e8c35a369d0db40a0/fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527", size = 2281060, upload-time = "2026-03-09T16:49:04.385Z" }, + { url = "https://files.pythonhosted.org/packages/cd/06/cc96468781a4dc8ae2f14f16f32b32f69bde18cb9384aad27ccc7adf76f7/fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8", size = 2331193, upload-time = "2026-03-09T16:49:06.598Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/985c1670aa6d82ef270f04cde11394c168f2002700353bd2bde405e59b8f/fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa", size = 2864929, upload-time = "2026-03-09T16:49:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/c409c8ceec0d3119e9ab0b7b1a2e3c76d1f4d66e4a9db5c59e6b7652e7df/fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c", size = 2412586, upload-time = "2026-03-09T16:49:11.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/8e300dbf7b4d135287c261ffd92ede02d9f48f0d2db14665fbc8b059588a/fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5", size = 5013708, upload-time = "2026-03-09T16:49:14.063Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/60d93477b653eeb1ddf5f9ec34be689b79234d82dbdded269ac0252715b8/fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee", size = 4964355, upload-time = "2026-03-09T16:49:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/6dc62bcc3c3598c28a3ecb77e69018869c3e109bd83031d4973c059d318b/fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc", size = 4953472, upload-time = "2026-03-09T16:49:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/82/b3/3af7592d9b254b7b7fec018135f8776bfa0d1ad335476c2791b1334dc5e4/fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f", size = 5094701, upload-time = "2026-03-09T16:49:21.67Z" }, + { url = "https://files.pythonhosted.org/packages/31/3d/976645583ab567d3ee75ff87b33aa1330fa2baeeeae5fc46210b4274dd45/fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3", size = 2279710, upload-time = "2026-03-09T16:49:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/e25245a30457595740041dba9d0ea8ec1b2517f2f1a6a741f15eba1a4edc/fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b", size = 2330291, upload-time = "2026-03-09T16:49:26.237Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/61f69298aa6e7c363dcf00dd6371a654676900abe27d1effd1a74b43e5d0/fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449", size = 2864222, upload-time = "2026-03-09T16:49:28.285Z" }, + { url = "https://files.pythonhosted.org/packages/c6/57/6b08756fe4455336b1fe160ab3c11fccc90768ccb6ee03fb0b45851aace4/fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00", size = 2410674, upload-time = "2026-03-09T16:49:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/db65b63bb1b824b63e602e9be21b18741ddc99bcf5a7850f9181159ae107/fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa", size = 4999387, upload-time = "2026-03-09T16:49:32.593Z" }, + { url = "https://files.pythonhosted.org/packages/86/c8/c6669e42d2f4efd60d38a3252cebbb28851f968890efb2b9b15f9d1092b0/fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1", size = 4912506, upload-time = "2026-03-09T16:49:34.927Z" }, + { url = "https://files.pythonhosted.org/packages/2e/49/0ae552aa098edd0ec548413fbf818f52ceb70535016215094a5ce9bf8f70/fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3", size = 4951202, upload-time = "2026-03-09T16:49:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/65/ae38fc8a4cea6f162d74cf11f58e9aeef1baa7d0e3d1376dabd336c129e5/fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0", size = 5060758, upload-time = "2026-03-09T16:49:39.464Z" }, + { url = "https://files.pythonhosted.org/packages/db/3d/bb797496f35c60544cd5af71ffa5aad62df14ef7286908d204cb5c5096fe/fonttools-4.62.0-cp314-cp314-win32.whl", hash = "sha256:273acb61f316d07570a80ed5ff0a14a23700eedbec0ad968b949abaa4d3f6bb5", size = 2283496, upload-time = "2026-03-09T16:49:42.448Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/91081ffe5881253177c175749cce5841f5ec6e931f5d52f4a817207b7429/fonttools-4.62.0-cp314-cp314-win_amd64.whl", hash = "sha256:a5f974006d14f735c6c878fc4b117ad031dc93638ddcc450ca69f8fd64d5e104", size = 2335426, upload-time = "2026-03-09T16:49:44.228Z" }, + { url = "https://files.pythonhosted.org/packages/f8/65/f47f9b3db1ec156a1f222f1089ba076b2cc9ee1d024a8b0a60c54258517e/fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95", size = 2947079, upload-time = "2026-03-09T16:49:46.471Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/bc62e5058a0c22cf02b1e0169ef0c3ca6c3247216d719f95bead3c05a991/fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887", size = 2448802, upload-time = "2026-03-09T16:49:48.328Z" }, + { url = "https://files.pythonhosted.org/packages/2b/df/bfaa0e845884935355670e6e68f137185ab87295f8bc838db575e4a66064/fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8", size = 5137378, upload-time = "2026-03-09T16:49:50.223Z" }, + { url = "https://files.pythonhosted.org/packages/32/32/04f616979a18b48b52e634988b93d847b6346260faf85ecccaf7e2e9057f/fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db", size = 4920714, upload-time = "2026-03-09T16:49:53.172Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2e/274e16689c1dfee5c68302cd7c444213cfddd23cf4620374419625037ec6/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1", size = 5016012, upload-time = "2026-03-09T16:49:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/b08117270626e7117ac2f89d732fdd4386ec37d2ab3a944462d29e6f89a1/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2", size = 5042766, upload-time = "2026-03-09T16:49:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/a48b73e54efa272ee65315a6331b30a9b3a98733310bc11402606809c50e/fonttools-4.62.0-cp314-cp314t-win32.whl", hash = "sha256:d28d5baacb0017d384df14722a63abe6e0230d8ce642b1615a27d78ffe3bc983", size = 2347785, upload-time = "2026-03-09T16:49:59.698Z" }, + { url = "https://files.pythonhosted.org/packages/f8/27/c67eab6dc3525bdc39586511b1b3d7161e972dacc0f17476dbaf932e708b/fonttools-4.62.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f9e20c4618f1e04190c802acae6dc337cb6db9fa61e492fd97cd5c5a9ff6d07", size = 2413914, upload-time = "2026-03-09T16:50:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" }, ] [[package]] @@ -1930,6 +2115,22 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, @@ -2070,8 +2271,8 @@ name = "github-copilot-sdk" version = "0.1.32" source = { registry = "https://pypi.org/simple" } dependencies = [ - { 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 = "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/cd/67/ebd002c14fe7d2640d0fff47a0b29fdb21ed239b597afa2d2c6f6cfebb0b/github_copilot_sdk-0.1.32-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d97bc39fbd4b51e0aea3405299da1e643838ddbf6bff284f688a2d8c20d82ff8", size = 58576987, upload-time = "2026-03-07T15:28:24.062Z" }, @@ -2139,10 +2340,16 @@ version = "3.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2151,7 +2358,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2160,7 +2366,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2169,7 +2374,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2178,7 +2382,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -2197,66 +2400,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] -[[package]] -name = "grpcio" -version = "1.67.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*' and sys_platform == 'darwin'", - "python_full_version == '3.12.*' and sys_platform == 'darwin'", - "python_full_version < '3.12' and sys_platform == 'darwin'", - "python_full_version == '3.13.*' and sys_platform == 'linux'", - "python_full_version == '3.12.*' and sys_platform == 'linux'", - "python_full_version < '3.12' and sys_platform == 'linux'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/20/53/d9282a66a5db45981499190b77790570617a604a38f3d103d0400974aeb5/grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732", size = 12580022, upload-time = "2024-10-29T06:30:07.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/2c/b60d6ea1f63a20a8d09c6db95c4f9a16497913fb3048ce0990ed81aeeca0/grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb", size = 5119075, upload-time = "2024-10-29T06:24:04.696Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9a/e1956f7ca582a22dd1f17b9e26fcb8229051b0ce6d33b47227824772feec/grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e", size = 11009159, upload-time = "2024-10-29T06:24:07.781Z" }, - { url = "https://files.pythonhosted.org/packages/43/a8/35fbbba580c4adb1d40d12e244cf9f7c74a379073c0a0ca9d1b5338675a1/grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f", size = 5629476, upload-time = "2024-10-29T06:24:11.444Z" }, - { url = "https://files.pythonhosted.org/packages/77/c9/864d336e167263d14dfccb4dbfa7fce634d45775609895287189a03f1fc3/grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc", size = 6239901, upload-time = "2024-10-29T06:24:14.2Z" }, - { url = "https://files.pythonhosted.org/packages/f7/1e/0011408ebabf9bd69f4f87cc1515cbfe2094e5a32316f8714a75fd8ddfcb/grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96", size = 5881010, upload-time = "2024-10-29T06:24:17.451Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7d/fbca85ee9123fb296d4eff8df566f458d738186d0067dec6f0aa2fd79d71/grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f", size = 6580706, upload-time = "2024-10-29T06:24:20.038Z" }, - { url = "https://files.pythonhosted.org/packages/75/7a/766149dcfa2dfa81835bf7df623944c1f636a15fcb9b6138ebe29baf0bc6/grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970", size = 6161799, upload-time = "2024-10-29T06:24:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/09/13/5b75ae88810aaea19e846f5380611837de411181df51fd7a7d10cb178dcb/grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744", size = 3616330, upload-time = "2024-10-29T06:24:25.775Z" }, - { url = "https://files.pythonhosted.org/packages/aa/39/38117259613f68f072778c9638a61579c0cfa5678c2558706b10dd1d11d3/grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5", size = 4354535, upload-time = "2024-10-29T06:24:28.614Z" }, - { url = "https://files.pythonhosted.org/packages/6e/25/6f95bd18d5f506364379eabc0d5874873cc7dbdaf0757df8d1e82bc07a88/grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953", size = 5089809, upload-time = "2024-10-29T06:24:31.24Z" }, - { url = "https://files.pythonhosted.org/packages/10/3f/d79e32e5d0354be33a12db2267c66d3cfeff700dd5ccdd09fd44a3ff4fb6/grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb", size = 10981985, upload-time = "2024-10-29T06:24:34.942Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/36fbc14b3542e3a1c20fb98bd60c4732c55a44e374a4eb68f91f28f14aab/grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0", size = 5588770, upload-time = "2024-10-29T06:24:38.145Z" }, - { url = "https://files.pythonhosted.org/packages/0d/af/bbc1305df60c4e65de8c12820a942b5e37f9cf684ef5e49a63fbb1476a73/grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af", size = 6214476, upload-time = "2024-10-29T06:24:41.006Z" }, - { url = "https://files.pythonhosted.org/packages/92/cf/1d4c3e93efa93223e06a5c83ac27e32935f998bc368e276ef858b8883154/grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e", size = 5850129, upload-time = "2024-10-29T06:24:43.553Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ca/26195b66cb253ac4d5ef59846e354d335c9581dba891624011da0e95d67b/grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75", size = 6568489, upload-time = "2024-10-29T06:24:46.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/94/16550ad6b3f13b96f0856ee5dfc2554efac28539ee84a51d7b14526da985/grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38", size = 6149369, upload-time = "2024-10-29T06:24:49.112Z" }, - { url = "https://files.pythonhosted.org/packages/33/0d/4c3b2587e8ad7f121b597329e6c2620374fccbc2e4e1aa3c73ccc670fde4/grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78", size = 3599176, upload-time = "2024-10-29T06:24:51.443Z" }, - { url = "https://files.pythonhosted.org/packages/7d/36/0c03e2d80db69e2472cf81c6123aa7d14741de7cf790117291a703ae6ae1/grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc", size = 4346574, upload-time = "2024-10-29T06:24:54.587Z" }, - { url = "https://files.pythonhosted.org/packages/12/d2/2f032b7a153c7723ea3dea08bffa4bcaca9e0e5bdf643ce565b76da87461/grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b", size = 5091487, upload-time = "2024-10-29T06:24:57.416Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ae/ea2ff6bd2475a082eb97db1104a903cf5fc57c88c87c10b3c3f41a184fc0/grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1", size = 10943530, upload-time = "2024-10-29T06:25:01.062Z" }, - { url = "https://files.pythonhosted.org/packages/07/62/646be83d1a78edf8d69b56647327c9afc223e3140a744c59b25fbb279c3b/grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af", size = 5589079, upload-time = "2024-10-29T06:25:04.254Z" }, - { url = "https://files.pythonhosted.org/packages/d0/25/71513d0a1b2072ce80d7f5909a93596b7ed10348b2ea4fdcbad23f6017bf/grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955", size = 6213542, upload-time = "2024-10-29T06:25:06.824Z" }, - { url = "https://files.pythonhosted.org/packages/76/9a/d21236297111052dcb5dc85cd77dc7bf25ba67a0f55ae028b2af19a704bc/grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8", size = 5850211, upload-time = "2024-10-29T06:25:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fe/70b1da9037f5055be14f359026c238821b9bcf6ca38a8d760f59a589aacd/grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62", size = 6572129, upload-time = "2024-10-29T06:25:12.853Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/7df509a2cd2a54814598caf2fb759f3e0b93764431ff410f2175a6efb9e4/grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb", size = 6149819, upload-time = "2024-10-29T06:25:15.803Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/bc3b0155600898fd10f16b79054e1cca6cb644fa3c250c0fe59385df5e6f/grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121", size = 3596561, upload-time = "2024-10-29T06:25:19.348Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/44759eca966720d0f3e1b105c43f8ad4590c97bf8eb3cd489656e9590baa/grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba", size = 4346042, upload-time = "2024-10-29T06:25:21.939Z" }, -] - [[package]] name = "grpcio" version = "1.78.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'darwin'", - "python_full_version >= '3.14' and sys_platform == 'linux'", - "python_full_version >= '3.14' and sys_platform == 'win32'", -] dependencies = [ - { name = "typing-extensions", 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 = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, @@ -2335,34 +2497,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.3.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/cb/9bb543bd987ffa1ee48202cc96a756951b734b79a542335c566148ade36c/hf_xet-1.3.2.tar.gz", hash = "sha256:e130ee08984783d12717444e538587fa2119385e5bd8fc2bb9f930419b73a7af", size = 643646, upload-time = "2026-02-27T17:26:08.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/01/928fd82663fb0ab455551a178303a2960e65029da66b21974594f3a20a94/hf_xet-1.4.0.tar.gz", hash = "sha256:48e6ba7422b0885c9bbd8ac8fdf5c4e1306c3499b82d489944609cc4eae8ecbd", size = 660350, upload-time = "2026-03-11T18:50:03.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/75/462285971954269432aad2e7938c5c7ff9ec7d60129cec542ab37121e3d6/hf_xet-1.3.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:335a8f36c55fd35a92d0062f4e9201b4015057e62747b7e7001ffb203c0ee1d2", size = 3761019, upload-time = "2026-02-27T17:25:49.441Z" }, - { url = "https://files.pythonhosted.org/packages/35/56/987b0537ddaf88e17192ea09afa8eca853e55f39a4721578be436f8409df/hf_xet-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c1ae4d3a716afc774e66922f3cac8206bfa707db13f6a7e62dfff74bfc95c9a8", size = 3521565, upload-time = "2026-02-27T17:25:47.469Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5c/7e4a33a3d689f77761156cc34558047569e54af92e4d15a8f493229f6767/hf_xet-1.3.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6dbdf231efac0b9b39adcf12a07f0c030498f9212a18e8c50224d0e84ab803d", size = 4176494, upload-time = "2026-02-27T17:25:40.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b3/71e856bf9d9a69b3931837e8bf22e095775f268c8edcd4a9e8c355f92484/hf_xet-1.3.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c1980abfb68ecf6c1c7983379ed7b1e2b49a1aaf1a5aca9acc7d48e5e2e0a961", size = 3955601, upload-time = "2026-02-27T17:25:38.376Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/aecf97b3f0a981600a67ff4db15e2d433389d698a284bb0ea5d8fcdd6f7f/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1c88fbd90ad0d27c46b77a445f0a436ebaa94e14965c581123b68b1c52f5fd30", size = 4154770, upload-time = "2026-02-27T17:25:56.756Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e1/3af961f71a40e09bf5ee909842127b6b00f5ab4ee3817599dc0771b79893/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:35b855024ca37f2dd113ac1c08993e997fbe167b9d61f9ef66d3d4f84015e508", size = 4394161, upload-time = "2026-02-27T17:25:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c3/859509bade9178e21b8b1db867b8e10e9f817ab9ac1de77cb9f461ced765/hf_xet-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:31612ba0629046e425ba50375685a2586e11fb9144270ebabd75878c3eaf6378", size = 3637377, upload-time = "2026-02-27T17:26:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/05/7f/724cfbef4da92d577b71f68bf832961c8919f36c60d28d289a9fc9d024d4/hf_xet-1.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:433c77c9f4e132b562f37d66c9b22c05b5479f243a1f06a120c1c06ce8b1502a", size = 3497875, upload-time = "2026-02-27T17:26:09.034Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/9d54c1ae1d05fb704f977eca1671747babf1957f19f38ae75c5933bc2dc1/hf_xet-1.3.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c34e2c7aefad15792d57067c1c89b2b02c1bbaeabd7f8456ae3d07b4bbaf4094", size = 3761076, upload-time = "2026-02-27T17:25:55.42Z" }, - { url = "https://files.pythonhosted.org/packages/f2/8a/08a24b6c6f52b5d26848c16e4b6d790bb810d1bf62c3505bed179f7032d3/hf_xet-1.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4bc995d6c41992831f762096020dc14a65fdf3963f86ffed580b596d04de32e3", size = 3521745, upload-time = "2026-02-27T17:25:54.217Z" }, - { url = "https://files.pythonhosted.org/packages/b5/db/a75cf400dd8a1a8acf226a12955ff6ee999f272dfc0505bafd8079a61267/hf_xet-1.3.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:959083c89dee30f7d6f890b36cdadda823386c4de63b1a30384a75bfd2ae995d", size = 4176301, upload-time = "2026-02-27T17:25:46.044Z" }, - { url = "https://files.pythonhosted.org/packages/01/40/6c4c798ffdd83e740dd3925c4e47793b07442a9efa3bc3866ba141a82365/hf_xet-1.3.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cfa760888633b08c01b398d212ce7e8c0d7adac6c86e4b20dfb2397d8acd78ee", size = 3955437, upload-time = "2026-02-27T17:25:44.703Z" }, - { url = "https://files.pythonhosted.org/packages/0c/09/9a3aa7c5f07d3e5cc57bb750d12a124ffa72c273a87164bd848f9ac5cc14/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3155a02e083aa21fd733a7485c7c36025e49d5975c8d6bda0453d224dd0b0ac4", size = 4154535, upload-time = "2026-02-27T17:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e0/831f7fa6d90cb47a230bc23284b502c700e1483bbe459437b3844cdc0776/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91b1dc03c31cbf733d35dc03df7c5353686233d86af045e716f1e0ea4a2673cf", size = 4393891, upload-time = "2026-02-27T17:26:06.607Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/6ed472fdce7f8b70f5da6e3f05be76816a610063003bfd6d9cea0bbb58a3/hf_xet-1.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:211f30098512d95e85ad03ae63bd7dd2c4df476558a5095d09f9e38e78cbf674", size = 3637583, upload-time = "2026-02-27T17:26:17.349Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/a069edc4570b3f8e123c0b80fadc94530f3d7b01394e1fc1bb223339366c/hf_xet-1.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4a6817c41de7c48ed9270da0b02849347e089c5ece9a0e72ae4f4b3a57617f82", size = 3497977, upload-time = "2026-02-27T17:26:14.966Z" }, - { url = "https://files.pythonhosted.org/packages/d8/28/dbb024e2e3907f6f3052847ca7d1a2f7a3972fafcd53ff79018977fcb3e4/hf_xet-1.3.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f93b7595f1d8fefddfede775c18b5c9256757824f7f6832930b49858483cd56f", size = 3763961, upload-time = "2026-02-27T17:25:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/e4/71/b99aed3823c9d1795e4865cf437d651097356a3f38c7d5877e4ac544b8e4/hf_xet-1.3.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a85d3d43743174393afe27835bde0cd146e652b5fcfdbcd624602daef2ef3259", size = 3526171, upload-time = "2026-02-27T17:25:50.968Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/907890ce6ef5598b5920514f255ed0a65f558f820515b18db75a51b2f878/hf_xet-1.3.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c2a054a97c44e136b1f7f5a78f12b3efffdf2eed3abc6746fc5ea4b39511633", size = 4180750, upload-time = "2026-02-27T17:25:43.125Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ad/bc7f41f87173d51d0bce497b171c4ee0cbde1eed2d7b4216db5d0ada9f50/hf_xet-1.3.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:06b724a361f670ae557836e57801b82c75b534812e351a87a2c739f77d1e0635", size = 3961035, upload-time = "2026-02-27T17:25:41.837Z" }, - { url = "https://files.pythonhosted.org/packages/73/38/600f4dda40c4a33133404d9fe644f1d35ff2d9babb4d0435c646c63dd107/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:305f5489d7241a47e0458ef49334be02411d1d0f480846363c1c8084ed9916f7", size = 4161378, upload-time = "2026-02-27T17:26:00.365Z" }, - { url = "https://files.pythonhosted.org/packages/00/b3/7bc1ff91d1ac18420b7ad1e169b618b27c00001b96310a89f8a9294fe509/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06cdbde243c85f39a63b28e9034321399c507bcd5e7befdd17ed2ccc06dfe14e", size = 4398020, upload-time = "2026-02-27T17:26:03.977Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0b/99bfd948a3ed3620ab709276df3ad3710dcea61976918cce8706502927af/hf_xet-1.3.2-cp37-abi3-win_amd64.whl", hash = "sha256:9298b47cce6037b7045ae41482e703c471ce36b52e73e49f71226d2e8e5685a1", size = 3641624, upload-time = "2026-02-27T17:26:13.542Z" }, - { url = "https://files.pythonhosted.org/packages/cc/02/9a6e4ca1f3f73a164c0cd48e41b3cc56585dcc37e809250de443d673266f/hf_xet-1.3.2-cp37-abi3-win_arm64.whl", hash = "sha256:83d8ec273136171431833a6957e8f3af496bee227a0fe47c7b8b39c106d1749a", size = 3503976, upload-time = "2026-02-27T17:26:12.123Z" }, + { url = "https://files.pythonhosted.org/packages/05/4b/2351e30dddc6f3b47b3da0a0693ec1e82f8303b1a712faa299cf3552002b/hf_xet-1.4.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:76725fcbc5f59b23ac778f097d3029d6623e3cf6f4057d99d1fce1a7e3cff8fc", size = 3796397, upload-time = "2026-03-11T18:49:47.382Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/3db90ec0afb4e26e3330b1346b89fe0e9a3b7bfc2d6a2b2262787790d25f/hf_xet-1.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:76f1f73bee81a6e6f608b583908aa24c50004965358ac92c1dc01080a21bcd09", size = 3556235, upload-time = "2026-03-11T18:49:45.785Z" }, + { url = "https://files.pythonhosted.org/packages/57/6e/2a662af2cbc6c0a64ebe9fcdb8faf05b5205753d45a75a3011bb2209d0b4/hf_xet-1.4.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1818c2e5d6f15354c595d5111c6eb0e5a30a6c5c1a43eeaec20f19607cff0b34", size = 4213145, upload-time = "2026-03-11T18:49:38.009Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4a/47c129affb540767e0e3e101039a95f4a73a292ec689c26e8f0c5b633f9d/hf_xet-1.4.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:70764d295f485db9cc9a6af76634ea00ec4f96311be7485f8f2b6144739b4ccf", size = 3991951, upload-time = "2026-03-11T18:49:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/76/81/ec516cfc6281cfeef027b0919166b2fe11ab61fbe6131a2c43fafbed8b68/hf_xet-1.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d3bd2a1e289f772c715ca88cdca8ceb3d8b5c9186534d5925410e531d849a3e", size = 4193205, upload-time = "2026-03-11T18:49:54.415Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/0945b5e542ed6c6ce758b589b27895a449deab630dfcdee5a6ee0f699d21/hf_xet-1.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:06da3797f1fdd9a8f8dbc8c1bddfa0b914789b14580c375d29c32ee35c2c66ca", size = 4431022, upload-time = "2026-03-11T18:49:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ad/a4859c55ab4b67a4fde2849be8bde81917f54062050419b821071f199a9c/hf_xet-1.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:30b9d8f384ccec848124d51d883e91f3c88d430589e02a7b6d867730ab8d53ac", size = 3674977, upload-time = "2026-03-11T18:50:06.369Z" }, + { url = "https://files.pythonhosted.org/packages/4b/17/5bf3791e3a53e597913c2a775a48a98aaded9c2ddb5d1afaedabb55e2ed8/hf_xet-1.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:07ffdbf7568fa3245b24d949f0f3790b5276fb7293a5554ac4ec02e5f7e2b38d", size = 3536778, upload-time = "2026-03-11T18:50:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a1/05a7f9d6069bf78405d3fc2464b6c76b167128501e13b4f1d6266e1d1f54/hf_xet-1.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2731044f3a18442f9f7a3dcf03b96af13dee311f03846a1df1f0553a3ea0fc6", size = 3796727, upload-time = "2026-03-11T18:49:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8a/67abc642c2b32efcb7a257cdad8555c2904e23f18a1b4fec3aef1ebfe0fc/hf_xet-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b6f3729335fbc4baef60fe14fe32ef13ac9d377bdc898148c541e20c6056b504", size = 3555869, upload-time = "2026-03-11T18:49:51.313Z" }, + { url = "https://files.pythonhosted.org/packages/19/3d/4765367c64ee70db15fa771d5b94bf12540b85076a1d3210ebbfec42d477/hf_xet-1.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c0c9f052738a024073d332c573275c8e33697a3ef3f5dd2fb4ef98216e1e74a", size = 4212980, upload-time = "2026-03-11T18:49:44.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/bf/6ad99ee0e7ca2318f912a87318e493d82d8f9aace6be81f774bd14b996df/hf_xet-1.4.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f44b2324be75bfa399735996ac299fd478684c48ce47d12a42b5f24b1a99ccb8", size = 3991136, upload-time = "2026-03-11T18:49:42.512Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/932e25c69699076088f57e3c14f83ccae87bac25e755994f3362acc908d5/hf_xet-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:01de78b1ceddf8b38da001f7cc728b3bc3eb956948b18e8a1997ad6fc80fbe9d", size = 4192676, upload-time = "2026-03-11T18:50:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/5e41339a294fd3450948989a47ecba9824d5bc1950cf767f928ecaf53a55/hf_xet-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cac8616e7a974105c3494735313f5ab0fb79b5accadec1a7a992859a15536a9", size = 4430729, upload-time = "2026-03-11T18:50:01.923Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c1/c3d8ed9b7118e9166b0cf71dfd501da82f1abe306387e34e0f3ee59553ec/hf_xet-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3a5d9cb25095ceb3beab4843ae2d1b3e5746371ddbf2e5849f7be6a7d6f44df4", size = 3674989, upload-time = "2026-03-11T18:50:12.633Z" }, + { url = "https://files.pythonhosted.org/packages/65/bc/ea26cf774063cb09d7aaaa6cba9d341fb72b42ea99b8a94ca254dbafbbb0/hf_xet-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9b777674499dc037317db372c90a2dd91329b5f1ee93c645bb89155bb974f5bf", size = 3536805, upload-time = "2026-03-11T18:50:11.082Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f9/a0b01945726aea81d2f213457cd5f5102a51e6fd1ca9f9769f561fb57501/hf_xet-1.4.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:981d2b5222c3baadf9567c135cf1d1073786f546b7745686978d46b5df179e16", size = 3799223, upload-time = "2026-03-11T18:49:49.884Z" }, + { url = "https://files.pythonhosted.org/packages/5d/30/ee62b0c00412f49a7e6f509f0104ee8808692278d247234336df48029349/hf_xet-1.4.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:cc8bd050349d0d7995ce7b3a3a18732a2a8062ce118a82431602088abb373428", size = 3560682, upload-time = "2026-03-11T18:49:48.633Z" }, + { url = "https://files.pythonhosted.org/packages/93/d0/0fe5c44dbced465a651a03212e1135d0d7f95d19faada692920cb56f8e38/hf_xet-1.4.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d0c38d2a280d814280b8c15eead4a43c9781e7bf6fc37843cffab06dcdc76b9", size = 4218323, upload-time = "2026-03-11T18:49:40.921Z" }, + { url = "https://files.pythonhosted.org/packages/73/df/7b3c99a4e50442039eae498e5c23db634538eb3e02214109880cf1165d4c/hf_xet-1.4.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a883f0250682ea888a1bd0af0631feda377e59ad7aae6fb75860ecee7ae0f93", size = 3997156, upload-time = "2026-03-11T18:49:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/a9/26/47dfedf271c21d95346660ae1698e7ece5ab10791fa6c4f20c59f3713083/hf_xet-1.4.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:99e1d9255fe8ecdf57149bb0543d49e7b7bd8d491ddf431eb57e114253274df5", size = 4199052, upload-time = "2026-03-11T18:49:57.097Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c0/346b9aad1474e881e65f998d5c1981695f0af045bc7a99204d9d86759a89/hf_xet-1.4.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b25f06ce42bd2d5f2e79d4a2d72f783d3ac91827c80d34a38cf8e5290dd717b0", size = 4434346, upload-time = "2026-03-11T18:49:58.67Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d6/88ce9d6caa397c3b935263d5bcbe3ebf6c443f7c76098b8c523d206116b9/hf_xet-1.4.0-cp37-abi3-win_amd64.whl", hash = "sha256:8d6d7816d01e0fa33f315c8ca21b05eca0ce4cdc314f13b81d953e46cc6db11d", size = 3678921, upload-time = "2026-03-11T18:50:09.496Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/17d99ed253b28a9550ca479867c66a8af4c9bcd8cdc9a26b0c8007c2000a/hf_xet-1.4.0-cp37-abi3-win_arm64.whl", hash = "sha256:cb8d9549122b5b42f34b23b14c6b662a88a586a919d418c774d8dbbc4b3ce2aa", size = 3541054, upload-time = "2026-03-11T18:50:07.963Z" }, ] [[package]] @@ -2396,6 +2558,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/bf/e4f7eb84ae3739e0138ce2e1892d99c5192355739c8403d5c572c599e5ac/httpdbg-2.1.5-py3-none-any.whl", hash = "sha256:57e353b4cefb37b4f6862b5b3e6c0e9da92999e94dc54fd393c9143b6644e89e", size = 88161, upload-time = "2025-11-23T14:50:05.223Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -2520,6 +2725,18 @@ version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, @@ -2655,97 +2872,131 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.4.9" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] [[package]] name = "langfuse" -version = "3.14.5" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2756,12 +3007,11 @@ dependencies = [ { 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 = "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/ec/6b/7a945e8bc56cbf343b6f6171fd45870b0ea80ea38463b2db8dd5a9dc04a2/langfuse-3.14.5.tar.gz", hash = "sha256:2f543ec1540053d39b08a50ed5992caf1cd54d472a55cb8e5dcf6d4fcb7ff631", size = 235474, upload-time = "2026-02-23T10:42:47.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/4b/8df7cd1684b46b6760d9c03893cbbc9ddbdd3f72eaf003b3859cec308587/langfuse-4.0.0.tar.gz", hash = "sha256:10df126c8d68e5746ff39a0a5100233f9f29446626c478e7770b1c775e4c4e17", size = 271030, upload-time = "2026-03-10T16:21:51.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/a1/10f04224542d6a57073c4f339b6763836a0899c98966f1d4ffcf56d2cf61/langfuse-3.14.5-py3-none-any.whl", hash = "sha256:5054b1c705ec69bce2d7077ce7419727ac629159428da013790979ca9cae77d5", size = 421240, upload-time = "2026-02-23T10:42:46.085Z" }, + { url = "https://files.pythonhosted.org/packages/84/56/7f14cbe189e8a10c805609d52a4578b5f1bca3d4060b531baf920827d4f5/langfuse-4.0.0-py3-none-any.whl", hash = "sha256:4afe6a114937fa544e7f5f86c34533c711f0f12ebf77480239b626da28bbae68", size = 462159, upload-time = "2026-03-10T16:21:49.701Z" }, ] [[package]] @@ -2770,6 +3020,18 @@ version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, @@ -2839,7 +3101,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.82.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2855,9 +3117,9 @@ dependencies = [ { 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/6c/00/49bb5c28e0dea0f5086229a2a08d5fdc6c8dc0d8e2acb2a2d1f7dd9f4b70/litellm-1.82.0.tar.gz", hash = "sha256:d388f52447daccbcaafa19a3e68d17b75f1374b5bf2cde680d65e1cd86e50d22", size = 16800355, upload-time = "2026-03-01T02:35:30.363Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/bd/6251e9a965ae2d7bc3342ae6c1a2d25dd265d354c502e63225451b135016/litellm-1.82.1.tar.gz", hash = "sha256:bc8427cdccc99e191e08e36fcd631c93b27328d1af789839eb3ac01a7d281890", size = 17197496, upload-time = "2026-03-10T09:10:04.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/89/eb28bfcf97d6b045c400e72eb047c381594467048c237dbb6c227764084c/litellm-1.82.0-py3-none-any.whl", hash = "sha256:5496b5d4532cccdc7a095c21cbac4042f7662021c57bc1d17be4e39838929e80", size = 14911978, upload-time = "2026-03-01T02:35:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl", hash = "sha256:a9ec3fe42eccb1611883caaf8b1bf33c9f4e12163f94c7d1004095b14c379eb2", size = 15341896, upload-time = "2026-03-10T09:10:00.702Z" }, ] [package.optional-dependencies] @@ -2891,20 +3153,20 @@ proxy = [ [[package]] name = "litellm-enterprise" -version = "0.1.33" +version = "0.1.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/76/62a57eb2a319b7db324f743f7b79f5fd581af758d940744cb23ff8f74310/litellm_enterprise-0.1.33.tar.gz", hash = "sha256:5e3c0de9c4b54694ebb3017c8e18ee1d40e02ebef86e9ebd9c006e445885d5a0", size = 56919, upload-time = "2026-02-28T18:37:36.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/ca/1c0bf58bbce062ad53d8f6ba85bc56e92a869b969f8ad7cd68d50423f42a/litellm_enterprise-0.1.34.tar.gz", hash = "sha256:d6fe43ef28728c1a6c131ba22667f1a8304035b70b435aa3e6cf1c2b91e84657", size = 57609, upload-time = "2026-03-09T11:12:12.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/b2/ed897ae1ec379868634d44d5184a950f76fd3bcdced1efde8ede0b403543/litellm_enterprise-0.1.33-py3-none-any.whl", hash = "sha256:ae262ecfca680a235095becd6215e412e5ceba90efef739e61e6096b121188a2", size = 120303, upload-time = "2026-02-28T18:37:35.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ad/23143b786081c8ebe1481a97e08058a6a5e9d5fc7fc4507256040aebcd42/litellm_enterprise-0.1.34-py3-none-any.whl", hash = "sha256:e2e8d084055f8c96e646d906d7dbee8bafee03d343247a349e8ccf2745fb7822", size = 121091, upload-time = "2026-03-09T11:12:11.09Z" }, ] [[package]] name = "litellm-proxy-extras" -version = "0.4.50" +version = "0.4.54" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/06/4269b662d98c747001a6e6a71b2f4afca5591c3c1d5ed1d4c538a92fd2e3/litellm_proxy_extras-0.4.50.tar.gz", hash = "sha256:0db0b8d81d382993d47f054ca973859beb111271f08e9eba6ab12f5c9163877e", size = 29140, upload-time = "2026-02-28T18:09:20.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b8/21a14fc27fb6d10f22c0758db63f4c1224fe8ea8aa4c7d0a26d5fa9da7b2/litellm_proxy_extras-0.4.54.tar.gz", hash = "sha256:2c777ecdf39901c4007ade4466eb6398985ed4000afe3fc2cac997e1169e8cee", size = 31265, upload-time = "2026-03-12T01:08:08.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/75/79485f4d5a0bc29ee115391d06a3d6bf4ef7678b98e1553daa6a266e84d7/litellm_proxy_extras-0.4.50-py3-none-any.whl", hash = "sha256:598f5da91cc830a8da341a0c75ae12da01c4b8eb44f933429244cf066151b079", size = 67090, upload-time = "2026-02-28T18:09:19.323Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7e/8dd3378eba2c7116562b6bd823fa929872e20bdcab93757358e6f3b4c9e3/litellm_proxy_extras-0.4.54-py3-none-any.whl", hash = "sha256:6621cf529f7f3647eb2dd0d2c417d91db8c7a05c3c592bef251887a122928837", size = 73661, upload-time = "2026-03-12T01:08:07.625Z" }, ] [[package]] @@ -2938,6 +3200,17 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -3011,11 +3284,13 @@ name = "matplotlib" version = "3.10.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { 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 = "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 = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", 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.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 = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3023,6 +3298,12 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, @@ -3065,6 +3346,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, @@ -3129,31 +3413,31 @@ wheels = [ [[package]] name = "microsoft-agents-activity" -version = "0.8.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/8a/3dbdf47f3ddabf646987ddf6f5260e77865c6812177b8759f1c7fc395ac8/microsoft_agents_activity-0.8.0.tar.gz", hash = "sha256:f9e7d92db119cf93dd0642a5e698732c40a450c064306ad076b0d83d95eae114", size = 61226, upload-time = "2026-02-24T18:28:49.283Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/f8/10/18b87c552112917496256d4e9e50a49bd712015d285f01a3c6e18cdfdd74/microsoft_agents_activity-0.8.0-py3-none-any.whl", hash = "sha256:16f0e7fd5ba8f64c43ceac514b7b22734e97b4478b7e97963232ca893cfe336d", size = 132917, upload-time = "2026-02-24T18:28:59.002Z" }, + { url = "https://files.pythonhosted.org/packages/25/8b/50ce2243e2900e94358f37009121145bb8224a388d95d704856aa2686667/microsoft_agents_activity-0.3.1-py3-none-any.whl", hash = "sha256:d7fc2e9cf2843ec8d6d42608b808b159a12cbb61e1fc7d7b1aaf29899f20746a", size = 111904, upload-time = "2025-09-09T23:19:50.722Z" }, ] [[package]] name = "microsoft-agents-copilotstudio-client" -version = "0.8.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/5d/a8567b03ff7d29d575aa8c4ebfb53d3f6ee8765cedd8550fae68e9df917d/microsoft_agents_copilotstudio_client-0.8.0.tar.gz", hash = "sha256:7416b2e7906977bd55b66f0b23853fb0c55d4a367cc8bf30cc8aba63d0949514", size = 27196, upload-time = "2026-02-24T18:28:52.033Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/6b/6b/999ab044edfe924f0330bd2ce200f3fa9c2a84550212587781c68d617679/microsoft_agents_copilotstudio_client-0.8.0-py3-none-any.whl", hash = "sha256:d00936e2a0b48482380d81695f00af86d71c82c0b464947cc723834b63c91553", size = 23715, upload-time = "2026-02-24T18:29:01.3Z" }, + { url = "https://files.pythonhosted.org/packages/97/35/8b4e9c691f2ce89653007f358519bbadff1fe0d495c3723c9dbbfa962a33/microsoft_agents_copilotstudio_client-0.3.1-py3-none-any.whl", hash = "sha256:cac7485405325b990202452c9c14848cbdb25d13e6cdaf7bd3eca3a5c1fb3989", size = 7420, upload-time = "2025-09-09T23:19:52.287Z" }, ] [[package]] name = "microsoft-agents-hosting-core" -version = "0.8.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3162,9 +3446,9 @@ dependencies = [ { 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/ad/8a/5ab47498bbc74989c30dbfbcb7862211117bdbeba4e3d844bb281c0e05bf/microsoft_agents_hosting_core-0.8.0.tar.gz", hash = "sha256:d3b34803f73d7f677b797733dfe5c561af876e8721c426d6379a762fe6e86fa4", size = 94079, upload-time = "2026-02-24T18:28:54.156Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/d8/ff/a1497b3ea63ab0658518fc18532179e5696c5d8d7b28683ec82c34323e54/microsoft_agents_hosting_core-0.8.0-py3-none-any.whl", hash = "sha256:603f53f14bebc7888b5664718bbd24038dafffdd282c81d0e635fca7acfc6aef", size = 139555, upload-time = "2026-02-24T18:29:03.479Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1b/543ddaa2daf8593911a02a07a6a78366d4a6a0053ec86a557c19fa97b60e/microsoft_agents_hosting_core-0.3.1-py3-none-any.whl", hash = "sha256:a4b41556b15321b74f539c5a0a89f70955459b7ec57e9e4b24e61bba27f1cbbc", size = 94573, upload-time = "2025-09-09T23:19:53.855Z" }, ] [[package]] @@ -3172,10 +3456,15 @@ name = "ml-dtypes" version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", 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.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/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/3a/c5b855752a70267ff729c349e650263adb3c206c29d28cc8ea7ace30a1d5/ml_dtypes-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b95e97e470fe60ed493fd9ae3911d8da4ebac16bd21f87ffa2b7c588bf22ea2c", size = 679735, upload-time = "2025-11-17T22:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/41/79/7433f30ee04bd4faa303844048f55e1eb939131c8e5195a00a96a0939b64/ml_dtypes-0.5.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4b801ebe0b477be666696bda493a9be8356f1f0057a57f1e35cd26928823e5a", size = 5051883, upload-time = "2025-11-17T22:31:33.658Z" }, + { url = "https://files.pythonhosted.org/packages/10/b1/8938e8830b0ee2e167fc75a094dea766a1152bde46752cd9bfc57ee78a82/ml_dtypes-0.5.4-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:388d399a2152dd79a3f0456a952284a99ee5c93d3e2f8dfe25977511e0515270", size = 5030369, upload-time = "2025-11-17T22:31:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/51886727bd16e2f47587997b802dd56398692ce8c6c03c2e5bb32ecafe26/ml_dtypes-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:4ff7f3e7ca2972e7de850e7b8fcbb355304271e2933dd90814c1cb847414d6e2", size = 210738, upload-time = "2025-11-17T22:31:37.43Z" }, { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, @@ -3247,8 +3536,29 @@ wheels = [ name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, @@ -3368,10 +3678,17 @@ dependencies = [ { 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/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, @@ -3410,11 +3727,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/59/81d0f4cad21484083466f278e6b392addd9f4205b48d45b5c8771670ebf8/narwhals-2.17.0.tar.gz", hash = "sha256:ebd5bc95bcfa2f8e89a8ac09e2765a63055162837208e67b42d6eeb6651d5e67", size = 620306, upload-time = "2026-02-23T09:44:34.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/b4/02a8add181b8d2cd5da3b667cd102ae536e8c9572ab1a130816d70a89edb/narwhals-2.18.0.tar.gz", hash = "sha256:1de5cee338bc17c338c6278df2c38c0dd4290499fcf70d75e0a51d5f22a6e960", size = 620222, upload-time = "2026-03-10T15:51:27.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/27/20770bd6bf8fbe1e16f848ba21da9df061f38d2e6483952c29d2bb5d1d8b/narwhals-2.17.0-py3-none-any.whl", hash = "sha256:2ac5307b7c2b275a7d66eeda906b8605e3d7a760951e188dcfff86e8ebe083dd", size = 444897, upload-time = "2026-02-23T09:44:32.006Z" }, + { url = "https://files.pythonhosted.org/packages/fe/75/0b4a10da17a44cf13567d08a9c7632a285297e46253263f1ae119129d10a/narwhals-2.18.0-py3-none-any.whl", hash = "sha256:68378155ee706ac9c5b25868ef62ecddd62947b6df7801a0a156bc0a615d2d0d", size = 444865, upload-time = "2026-03-10T15:51:24.085Z" }, ] [[package]] @@ -3426,10 +3743,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + [[package]] name = "numpy" version = "2.4.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" }, @@ -3516,15 +3914,15 @@ wheels = [ [[package]] name = "ollama" -version = "0.6.1" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +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 = [ - { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/be/f6/2091e50b8b6c3e6901f6eab283d5efd66fb71c86ddb1b4d68766c3eeba0f/ollama-0.5.3-py3-none-any.whl", hash = "sha256:a8303b413d99a9043dbf77ebf11ced672396b59bec27e6d5db67c88f01b279d2", size = 13490, upload-time = "2025-08-07T21:44:09.353Z" }, ] [[package]] @@ -3548,7 +3946,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.11.1" +version = "0.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3559,9 +3957,9 @@ dependencies = [ { 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'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/5e/79875ab7f0f2da8247d76616001ab3a82f6b128262a5c69367530689e69c/openai_agents-0.11.1.tar.gz", hash = "sha256:b2bec1a780a2e2f2419e9688931eb65649bb5283f99e946018d4f1b67d4e95ca", size = 2582366, upload-time = "2026-03-09T06:34:07.701Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/2e/402d3bfd6432c503bab699ece49e6febe38c64ade3365ae4fe31e7b3cba1/openai_agents-0.12.0.tar.gz", hash = "sha256:086d5cd16815d40a88231cbfd9dcca594cdf8596c6efd4859dcbafdfb31068ba", size = 2604305, upload-time = "2026-03-12T08:52:42.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/e9/d8d8a39a2e3c5fb1a538a13a6928f4223ff6664b8ba2a6137187b0f69370/openai_agents-0.11.1-py3-none-any.whl", hash = "sha256:4fda67bfe2aab4a1cd4a701d4e8d3d1eb849ba66aeea51295dbedf8a9e52cdb1", size = 434624, upload-time = "2026-03-09T06:34:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2c/8f03b5a56329559573e692d6dc2f02c3cbbe4fcd07f9c5d81b3c280e80e7/openai_agents-0.12.0-py3-none-any.whl", hash = "sha256:24f5cc5d6213dfcda42188918ad0a739861aa505f4ef738ee07b69169faf5c09", size = 446876, upload-time = "2026-03-12T08:52:40.779Z" }, ] [[package]] @@ -3624,8 +4022,7 @@ version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, 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 = "grpcio", version = "1.78.0", source = { registry = "https://pypi.org/simple" }, 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 = "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'" }, @@ -3711,15 +4108,11 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions-ai" -version = "0.4.15" +version = "0.4.13" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { 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'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/75/455c15f8360b475dd31101a87eab316420388486f7941bf019cbf4e63d5b/opentelemetry_semantic_conventions_ai-0.4.15.tar.gz", hash = "sha256:12de172d1e11d21c6e82bbf578c7e8a713589a7fda76af9ed785632564a28b81", size = 18595, upload-time = "2026-03-02T15:36:50.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e6/40b59eda51ac47009fb47afcdf37c6938594a0bd7f3b9fadcbc6058248e3/opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036", size = 5368, upload-time = "2025-08-22T10:14:17.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/49/819fb212386f77cfd93f81bd916d674f0e735f87c8ac2262ed14e3b852c2/opentelemetry_semantic_conventions_ai-0.4.15-py3-none-any.whl", hash = "sha256:011461f1fba30f27035c49ab3b8344367adc72da0a6c8d3c7428303c6779edc9", size = 5999, upload-time = "2026-03-02T15:36:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/35/b5/cf25da2218910f0d6cdf7f876a06bed118c4969eacaf60a887cbaef44f44/opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5", size = 6080, upload-time = "2025-08-22T10:14:16.477Z" }, ] [[package]] @@ -3758,6 +4151,19 @@ version = "3.11.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, + { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, @@ -3829,14 +4235,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + [[package]] name = "pandas" version = "3.0.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] dependencies = [ - { name = "numpy", 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 = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.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 = "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/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } wheels = [ @@ -3913,6 +4399,17 @@ version = "12.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, @@ -4032,6 +4529,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { 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/05/9b/e717572686bbf23e17483389c1bf3a381ca2427c84c7e0af0cdc0f23fccc/poethepoet-0.42.1.tar.gz", hash = "sha256:205747e276062c2aaba8afd8a98838f8a3a0237b7ab94715fab8d82718aac14f", size = 93209, upload-time = "2026-02-26T22:57:50.883Z" } wheels = [ @@ -4080,7 +4578,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.7" +version = "7.9.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4090,9 +4588,9 @@ dependencies = [ { name = "six", 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/16/08/e5064ae25749367f38f6d204ce876a045ecf4fd01ed0e66477364925416c/posthog-7.9.7.tar.gz", hash = "sha256:35dcaf4acc37b386b5ebcd6037cc80821e88d359627c0f61537c667c52359483", size = 175634, upload-time = "2026-03-05T22:09:51.979Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/8a/3e4dd145d7d5aaad856d522c61475c51ee80b512b6446bfb3966b2dedf66/posthog-7.9.7-py3-none-any.whl", hash = "sha256:204e47c27dcc230d0bc9b323709c36f98f86e79fa8190caea3b1fbc3c999b1a0", size = 201316, upload-time = "2026-03-05T22:09:50.18Z" }, + { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, ] [[package]] @@ -4138,6 +4636,21 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, @@ -4278,6 +4791,13 @@ version = "23.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, + { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, + { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, @@ -4393,6 +4913,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, @@ -4471,6 +5004,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, @@ -4595,10 +5136,12 @@ version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ @@ -4610,6 +5153,7 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { 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')" }, ] @@ -4739,6 +5283,9 @@ name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, @@ -4759,6 +5306,15 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -4813,10 +5369,10 @@ name = "qdrant-client" version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, 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 = "grpcio", version = "1.78.0", source = { registry = "https://pypi.org/simple" }, 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 = "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", 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.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 = "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'" }, @@ -4829,14 +5385,14 @@ wheels = [ [[package]] name = "redis" -version = "7.1.1" +version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" }, + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, ] [[package]] @@ -4846,7 +5402,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { 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", 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.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 = "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'" }, @@ -4878,6 +5435,23 @@ version = "2026.2.28" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, + { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, + { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, + { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, @@ -5010,6 +5584,20 @@ version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, @@ -5165,25 +5753,88 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, ] [[package]] name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] dependencies = [ - { name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { 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.4.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 = "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 = "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/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5225,12 +5876,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, ] +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", +] +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + [[package]] name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", +] dependencies = [ - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.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/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5302,8 +6028,10 @@ version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pandas", 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.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 = "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.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/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } wheels = [ @@ -5316,6 +6044,16 @@ version = "1.3.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" }, { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, @@ -5376,6 +6114,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" }, { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, @@ -5383,11 +6124,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] @@ -5445,6 +6186,13 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, @@ -5549,7 +6297,8 @@ dependencies = [ { 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", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or 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.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 = "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'" }, @@ -5558,7 +6307,8 @@ dependencies = [ { 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", 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.8.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'" }, @@ -5604,6 +6354,13 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" }, + { url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" }, { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, @@ -5672,6 +6429,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, ] [[package]] @@ -5881,15 +6642,27 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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]] @@ -5898,6 +6671,12 @@ version = "0.21.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload-time = "2024-10-14T23:37:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload-time = "2024-10-14T23:37:25.129Z" }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload-time = "2024-10-14T23:37:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload-time = "2024-10-14T23:37:29.385Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload-time = "2024-10-14T23:37:32.048Z" }, { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, @@ -5924,6 +6703,9 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, @@ -5933,6 +6715,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -5945,12 +6729,126 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + [[package]] name = "websockets" version = "15.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, @@ -5984,6 +6882,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] @@ -6014,6 +6918,16 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, @@ -6078,6 +6992,24 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, From 83ce6a9602ec6c61bb44a77dd86f518cd790f22b Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 13 Mar 2026 10:38:55 -0700 Subject: [PATCH 08/25] Sanitize user input in log statements for durable agent samples. (#4656) --- .../06_LongRunningTools/Tools.cs | 19 +++++++++++---- .../08_ReliableStreaming/FunctionTriggers.cs | 24 +++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs index 0694c8ea58..4352e1d8d6 100644 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs +++ b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/Tools.cs @@ -17,7 +17,7 @@ internal sealed class Tools(ILogger logger) [Description("Starts a content generation workflow and returns the instance ID for tracking.")] public string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic) { - this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", topic); + this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", SanitizeLogValue(topic)); const int MaxReviewAttempts = 3; const float ApprovalTimeoutHours = 72; @@ -34,7 +34,7 @@ internal sealed class Tools(ILogger logger) this._logger.LogInformation( "Content generation workflow scheduled to be started for topic '{Topic}' with instance ID: {InstanceId}", - topic, + SanitizeLogValue(topic), instanceId); return $"Workflow started with instance ID: {instanceId}"; @@ -45,7 +45,7 @@ internal sealed class Tools(ILogger logger) [Description("The instance ID of the workflow to check")] string instanceId, [Description("Whether to include detailed information")] bool includeDetails = true) { - this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", instanceId); + this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", SanitizeLogValue(instanceId)); // Get the current agent context using the session-static property OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync( @@ -54,7 +54,7 @@ internal sealed class Tools(ILogger logger) if (status is null) { - this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", instanceId); + this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", SanitizeLogValue(instanceId)); return new { instanceId, @@ -78,7 +78,16 @@ internal sealed class Tools(ILogger logger) [Description("The instance ID of the workflow to submit feedback for")] string instanceId, [Description("Feedback to submit")] HumanApprovalResponse feedback) { - this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", instanceId); + this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", SanitizeLogValue(instanceId)); await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback); } + + /// + /// Sanitizes a user-provided value for safe inclusion in log entries + /// by removing control characters that could be used for log forging. + /// + private static string SanitizeLogValue(string value) => + value + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); } diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs index 8ae1ee348e..97dc45795f 100644 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs +++ b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/FunctionTriggers.cs @@ -157,8 +157,8 @@ public sealed class FunctionTriggers this._logger.LogInformation( "Resuming stream for conversation {ConversationId} from cursor: {Cursor}", - conversationId, - cursor ?? "(beginning)"); + SanitizeLogValue(conversationId), + SanitizeLogValue(cursor) ?? "(beginning)"); // Check Accept header to determine response format // text/plain = raw text output (ideal for terminals) @@ -205,7 +205,7 @@ public sealed class FunctionTriggers { if (chunk.Error != null) { - this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error); + this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", SanitizeLogValue(conversationId), chunk.Error); await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken); break; } @@ -224,7 +224,7 @@ public sealed class FunctionTriggers } catch (OperationCanceledException) { - this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId); + this._logger.LogInformation("Client disconnected from stream {ConversationId}", SanitizeLogValue(conversationId)); } return new EmptyResult(); @@ -316,4 +316,20 @@ public sealed class FunctionTriggers await response.WriteAsync(sb.ToString()); } + + /// + /// Sanitizes a user-provided value for safe inclusion in log entries + /// by removing control characters that could be used for log forging. + /// + private static string? SanitizeLogValue(string? value) + { + if (value is null) + { + return null; + } + + return value + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); + } } From c67d3523ae33fc67c98ec9b9cdd407133d42e829 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 13 Mar 2026 11:16:46 -0700 Subject: [PATCH 09/25] .NET: [Durable Agents] Filter empty AIContent from durable agent state responses (#4670) * Filter empty AIContent from durable agent state responses Prevent opaque AIContent objects (e.g., with only RawRepresentation set) from being stored in durable entity state, where they serialize to empty JSON payloads. Base AIContent instances are kept only if they have Annotations or AdditionalProperties. Fixes https://github.com/microsoft/agent-framework/issues/4481 * Update CHANGELOG.md and fix linter violation --- .../CHANGELOG.md | 8 + .../State/DurableAgentStateResponse.cs | 20 ++- .../State/DurableAgentStateResponseTests.cs | 142 ++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index e3e90fdae0..74a52faf6f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -4,6 +4,12 @@ ### Changed +- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670)) + +## v1.0.0-preview.260311.1 + +### Changed + - Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679)) - Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843)) - Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067)); @@ -16,6 +22,8 @@ - Marked all `RunAsync` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803)) - Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973)) +NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file. + ## v1.0.0-preview.251204.1 - Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs index 612ff4b48f..fb9f23df95 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateResponse.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask.State; @@ -28,7 +29,10 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry { CorrelationId = correlationId, CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow, - Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(), + Messages = response.Messages + .Where(HasSerializableContent) + .Select(DurableAgentStateMessage.FromChatMessage) + .ToList(), Usage = DurableAgentStateUsage.FromUsage(response.Usage) }; } @@ -46,4 +50,18 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry Usage = this.Usage?.ToUsageDetails(), }; } + + // Checks whether a ChatMessage has any content that will produce meaningful serialized data. + // Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable. + // Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and + // AdditionalProperties. We keep the message if any base AIContent has annotations or additional + // properties set. NOTE: if AIContent gains new serializable properties in the future, this check + // should be updated accordingly. + private static bool HasSerializableContent(ChatMessage message) + { + return message.Contents.Any(c => + c.GetType() != typeof(AIContent) || + c.Annotations?.Count > 0 || + c.AdditionalProperties?.Count > 0); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs new file mode 100644 index 0000000000..a974f9d974 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateResponseTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State; + +public sealed class DurableAgentStateResponseTests +{ + [Fact] + public void FromResponseDropsMessagesContainingOnlyOpaqueContent() + { + // Arrange: one message with real text, one with only opaque AIContent + ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!") + { + CreatedAt = DateTimeOffset.UtcNow + }; + ChatMessage opaqueOnlyMessage = new(ChatRole.Assistant, [ + new AIContent + { + RawRepresentation = new { kind = "sessionEvent", sessionId = "s123" } + }]) + { + CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) + }; + + AgentResponse response = new(new List { usefulMessage, opaqueOnlyMessage }) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response); + + // Assert: only the useful message survives + DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); + Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + + // Round-trip to verify the content is correct + AgentResponse convertedResponse = durableResponse.ToResponse(); + ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages); + TextContent textContent = Assert.IsType(Assert.Single(convertedMessage.Contents)); + Assert.Equal("Hello, world!", textContent.Text); + } + + [Fact] + public void FromResponseKeepsMessagesWithMixedContent() + { + // Arrange: one message with both real text and opaque AIContent + ChatMessage mixedMessage = new(ChatRole.Assistant, [ + new TextContent("Some useful text"), + new AIContent { RawRepresentation = new { kind = "metadata" } }]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + AgentResponse response = new(new List { mixedMessage }) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-456", response); + + // Assert: the message is kept because it contains at least one serializable content + DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages); + Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role); + } + + [Fact] + public void FromResponseDropsAllMessagesWhenAllAreOpaque() + { + // Arrange: all messages contain only opaque AIContent + ChatMessage opaque1 = new(ChatRole.Assistant, [ + new AIContent { RawRepresentation = new { kind = "event1" } }]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + ChatMessage opaque2 = new(ChatRole.Assistant, [ + new AIContent { RawRepresentation = new { kind = "event2" } }]) + { + CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1) + }; + + AgentResponse response = new(new List { opaque1, opaque2 }) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response); + + // Assert: no messages stored + Assert.Empty(durableResponse.Messages); + } + + [Fact] + public void FromResponseKeepsBaseAIContentWithAnnotations() + { + // Arrange: base AIContent with annotations should be kept + AIContent contentWithAnnotations = new() + { + RawRepresentation = new { kind = "event" }, + Annotations = [new AIAnnotation() { AdditionalProperties = new() { ["cite"] = "ref-1" } }] + }; + ChatMessage message = new(ChatRole.Assistant, [contentWithAnnotations]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-ann", response); + + // Assert: message is kept because the AIContent has annotations + Assert.Single(durableResponse.Messages); + } + + [Fact] + public void FromResponseKeepsBaseAIContentWithAdditionalProperties() + { + // Arrange: base AIContent with additional properties should be kept + AIContent contentWithProps = new() + { + RawRepresentation = new { kind = "event" }, + AdditionalProperties = new() { ["custom_key"] = "custom_value" } + }; + ChatMessage message = new(ChatRole.Assistant, [contentWithProps]) + { + CreatedAt = DateTimeOffset.UtcNow + }; + + AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow }; + + // Act + DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-props", response); + + // Assert: message is kept because the AIContent has additional properties + Assert.Single(durableResponse.Messages); + } +} From 052ba7be0797e1e7fa17e0049c741ae5ba6d7c04 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 13 Mar 2026 21:03:48 +0100 Subject: [PATCH 10/25] Python: normalize empty MCP tool output to null (#4683) * Python: normalize empty MCP tool output to null * Python: hardcode null for empty MCP output --- python/packages/core/agent_framework/_mcp.py | 5 ++--- python/packages/core/tests/core/test_mcp.py | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 28c5f6db6a..81227c7e73 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import base64 +import json import logging import re import sys @@ -87,8 +88,6 @@ def _parse_prompt_result_from_mcp( Returns: A string representation of the prompt result. """ - import json - parts: list[str] = [] for message in mcp_type.messages: content = message.content @@ -194,7 +193,7 @@ def _parse_tool_result_from_mcp( result.append(Content.from_text(str(item))) if not result: - result.append(Content.from_text("")) + result.append(Content.from_text("null")) return result diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index df3187673a..70aff972fe 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -195,13 +195,16 @@ def test_parse_tool_result_from_mcp_meta_not_in_string(): def test_parse_tool_result_from_mcp_empty_content(): - """Test that empty content produces list with empty text Content.""" + """Test that empty MCP content normalizes to JSON null text content.""" mcp_result = types.CallToolResult(content=[]) result = _parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 assert result[0].type == "text" - assert result[0].text == "" + assert result[0].text == "null" + + function_result = Content.from_function_result(call_id="call_null", result=result) + assert function_result.result == "null" def test_parse_tool_result_from_mcp_audio_content(): From 2f4c4aa6142127b084c0ca7fc2c62874079d447f Mon Sep 17 00:00:00 2001 From: Laveesh Rohra Date: Fri, 13 Mar 2026 16:15:56 -0700 Subject: [PATCH 11/25] Python: Remove bad dependency (#4696) * Remove bad dependency in requirements * Remove bad dependency in requirements.txt --- .../azure_functions/09_workflow_shared_state/requirements.txt | 1 - .../04-hosting/azure_functions/12_workflow_hitl/requirements.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt index 5739f93aa3..39ad8a124f 100644 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt +++ b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt @@ -1,3 +1,2 @@ agent-framework-azurefunctions azure-identity -agents-maf \ No newline at end of file diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt index 85e158b8d4..39ad8a124f 100644 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt +++ b/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt @@ -1,3 +1,2 @@ agent-framework-azurefunctions azure-identity -agents-maf From 1b7940c91e045c563faafe11d5b03067f4ea7b16 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sat, 14 Mar 2026 14:54:05 +0100 Subject: [PATCH 12/25] Python: keep MCP cleanup on the owner task (#4687) * Python: keep MCP cleanup on owner task * Avoid MCP owner task deadlocks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MCP owner-task timeout tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 131 ++++++++++--- python/packages/core/tests/core/test_mcp.py | 192 ++++++++++++++----- 2 files changed, 256 insertions(+), 67 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 81227c7e73..5901e34dd9 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -480,6 +480,10 @@ class MCPTool: self.load_prompts_flag = load_prompts self.parse_prompt_results = parse_prompt_results self._exit_stack = AsyncExitStack() + self._lifecycle_lock = asyncio.Lock() + self._lifecycle_request_lock = asyncio.Lock() + self._lifecycle_queue: asyncio.Queue[tuple[str, bool, asyncio.Future[None]]] | None = None + self._lifecycle_owner_task: asyncio.Task[None] | None = None self.session = session self.request_timeout = request_timeout self.client = client @@ -510,39 +514,113 @@ class MCPTool: filtered_functions.append(func) return filtered_functions + async def _ensure_lifecycle_owner(self) -> None: + async with self._lifecycle_lock: + if self._lifecycle_owner_task is not None and not self._lifecycle_owner_task.done(): + return + + self._lifecycle_queue = asyncio.Queue() + self._lifecycle_owner_task = asyncio.create_task( + self._run_lifecycle_owner(), + name=f"mcp-lifecycle:{self.name}", + ) + + async def _run_lifecycle_owner(self) -> None: + queue = self._lifecycle_queue + if queue is None: + return + + stop_error: BaseException | None = None + try: + while True: + action, reset, future = await queue.get() + + try: + if action == "connect": + await self._connect_on_owner(reset=reset) + elif action == "close": + await self._close_on_owner() + else: + raise RuntimeError(f"Unknown MCP lifecycle action: {action}") + except asyncio.CancelledError as ex: + stop_error = ex + if not future.done(): + future.set_exception(ex) + raise + except Exception as ex: + if not future.done(): + future.set_exception(ex) + else: + if not future.done(): + future.set_result(None) + + if action == "close": + return + except asyncio.CancelledError as ex: + stop_error = ex + raise + finally: + while True: + try: + _, _, future = queue.get_nowait() + except asyncio.QueueEmpty: + break + if not future.done(): + future.set_exception(stop_error or RuntimeError("MCP lifecycle owner stopped unexpectedly.")) + + self._lifecycle_queue = None + self._lifecycle_owner_task = None + + def _is_lifecycle_owner_task(self) -> bool: + owner_task = self._lifecycle_owner_task + return owner_task is not None and asyncio.current_task() is owner_task + + async def _run_on_lifecycle_owner(self, action: str, *, reset: bool = False) -> None: + await self._ensure_lifecycle_owner() + + if self._is_lifecycle_owner_task(): + if action == "connect": + await self._connect_on_owner(reset=reset) + elif action == "close": + await self._close_on_owner() + else: + raise RuntimeError(f"Unknown MCP lifecycle action: {action}") + return + + queue = self._lifecycle_queue + if queue is None: + raise RuntimeError("MCP lifecycle owner is not available.") + + future = asyncio.get_running_loop().create_future() + await queue.put((action, reset, future)) + await future + async def _safe_close_exit_stack(self) -> None: - """Safely close the exit stack, handling cross-task boundary errors. - - anyio's cancel scopes are bound to the task they were created in. - If aclose() is called from a different task (e.g., during streaming reconnection), - anyio will raise a RuntimeError or CancelledError. In this case, we log a warning - and allow garbage collection to clean up the resources. - - Known error variants: - - "Attempted to exit cancel scope in a different task than it was entered in" - - "Attempted to exit a cancel scope that isn't the current task's current cancel scope" - - CancelledError from anyio cancel scope cleanup - """ + """Safely close the exit stack, handling unexpected cleanup failures.""" try: await self._exit_stack.aclose() except RuntimeError as e: error_msg = str(e).lower() - # Check for anyio cancel scope errors (multiple variants exist) if "cancel scope" in error_msg: logger.warning( "Could not cleanly close MCP exit stack due to cancel scope error. " - "Old resources will be garbage collected. Error: %s", + "This indicates MCP lifecycle ownership was lost. Error: %s", e, ) else: raise except asyncio.CancelledError: - # CancelledError can occur during cleanup when cancel scopes are involved - logger.warning( - "Could not cleanly close MCP exit stack due to cancellation. Old resources will be garbage collected." - ) + logger.warning("Could not cleanly close MCP exit stack because the lifecycle owner task was cancelled.") async def connect(self, *, reset: bool = False) -> None: + if self._is_lifecycle_owner_task(): + await self._connect_on_owner(reset=reset) + return + + async with self._lifecycle_request_lock: + await self._run_on_lifecycle_owner("connect", reset=reset) + + async def _connect_on_owner(self, *, reset: bool = False) -> None: """Connect to the MCP server. Establishes a connection to the MCP server, initializes the session, @@ -844,14 +922,23 @@ class MCPTool: break params = types.PaginatedRequestParams(cursor=tool_list.nextCursor) + async def _close_on_owner(self) -> None: + await self._safe_close_exit_stack() + self._exit_stack = AsyncExitStack() + self.session = None + self.is_connected = False + async def close(self) -> None: """Disconnect from the MCP server. Closes the connection and cleans up resources. """ - await self._safe_close_exit_stack() - self.session = None - self.is_connected = False + if self._is_lifecycle_owner_task(): + await self._close_on_owner() + return + + async with self._lifecycle_request_lock: + await self._run_on_lifecycle_owner("close") @abstractmethod def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: @@ -1043,7 +1130,7 @@ class MCPTool: except ToolException: raise except Exception as ex: - await self._safe_close_exit_stack() + await self.close() raise ToolExecutionException("Failed to enter context manager.", inner_exception=ex) from ex async def __aexit__( diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 70aff972fe..b29ec1a794 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2525,67 +2525,169 @@ async def test_mcp_tool_get_prompt_reconnection_on_closed_resource_error(): assert "failed to reconnect" in str(exc_info.value).lower() -async def test_mcp_tool_reconnection_handles_cross_task_cancel_scope_error(): - """Test that reconnection gracefully handles anyio cancel scope errors. +async def test_mcp_tool_close_cleans_up_in_original_task(caplog): + """Closing an MCP tool from another task should still unwind contexts in the owner task.""" + import asyncio - This tests the fix for the bug where calling connect(reset=True) from a - different task than where the connection was originally established would - cause: RuntimeError: Attempted to exit cancel scope in a different task - than it was entered in + class TaskBoundTransportContext: + def __init__(self) -> None: + self.enter_task = None + self.exit_task = None + self.closed_cleanly = False - This happens when using multiple MCP tools with AG-UI streaming - the first - tool call succeeds, but when the connection closes, the second tool call - triggers a reconnection from within the streaming loop (a different task). - """ - from contextlib import AsyncExitStack + async def __aenter__(self): + self.enter_task = asyncio.current_task() + return (Mock(), Mock()) - from agent_framework._mcp import MCPStdioTool + async def __aexit__(self, exc_type, exc, tb): + self.exit_task = asyncio.current_task() + if self.exit_task is not self.enter_task: + raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") + self.closed_cleanly = True + return - # Use load_tools=False and load_prompts=False to avoid triggering them during connect() - tool = MCPStdioTool( + tool = MCPStreamableHTTPTool( name="test_server", - command="test_command", - args=["arg1"], + url="https://example.com/mcp", load_tools=False, load_prompts=False, ) - # Mock the exit stack to raise the cross-task cancel scope error - mock_exit_stack = AsyncMock(spec=AsyncExitStack) - mock_exit_stack.aclose = AsyncMock( - side_effect=RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") - ) - tool._exit_stack = mock_exit_stack - tool.session = Mock() - tool.is_connected = True + transport_context = TaskBoundTransportContext() + mock_session = Mock() + mock_session._request_id = 1 + mock_session.initialize = AsyncMock() - # Mock get_mcp_client to return a mock transport - mock_transport = (Mock(), Mock()) - mock_context = AsyncMock() - mock_context.__aenter__ = AsyncMock(return_value=mock_transport) - mock_context.__aexit__ = AsyncMock() + mock_session_context = AsyncMock() + mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_context.__aexit__ = AsyncMock(return_value=None) with ( - patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession") as mock_session_class, + patch.object(tool, "get_mcp_client", return_value=transport_context), + patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), ): - mock_session = Mock() - mock_session._request_id = 1 - mock_session.initialize = AsyncMock() - mock_session.set_logging_level = AsyncMock() - mock_session_context = AsyncMock() - mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) - mock_session_context.__aexit__ = AsyncMock() - mock_session_class.return_value = mock_session_context + await asyncio.create_task(tool.connect()) - # This should NOT raise even though aclose() raised the cancel scope error - # The _safe_close_exit_stack method should catch and log the error - await tool.connect(reset=True) + caplog.clear() + with caplog.at_level(logging.WARNING, logger=logger.name): + await tool.close() - # Verify a new exit stack was created (the old mock was replaced) - assert tool._exit_stack is not mock_exit_stack - assert tool.session is not None + assert transport_context.closed_cleanly is True + assert transport_context.exit_task is transport_context.enter_task + assert not any("cancel scope" in record.getMessage().lower() for record in caplog.records) + + +async def test_mcp_tool_connect_reset_cleans_up_in_original_task(caplog): + """Resetting an MCP tool from another task should unwind and reconnect on the owner task.""" + import asyncio + + class TaskBoundTransportContext: + def __init__(self) -> None: + self.enter_task = None + self.exit_task = None + self.closed_cleanly = False + + async def __aenter__(self): + self.enter_task = asyncio.current_task() + return (Mock(), Mock()) + + async def __aexit__(self, exc_type, exc, tb): + self.exit_task = asyncio.current_task() + if self.exit_task is not self.enter_task: + raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") + self.closed_cleanly = True + return + + tool = MCPStreamableHTTPTool( + name="test_server", + url="https://example.com/mcp", + load_tools=False, + load_prompts=False, + ) + + transport_contexts = [TaskBoundTransportContext(), TaskBoundTransportContext()] + sessions = [] + session_contexts = [] + for _ in range(2): + session = Mock() + session._request_id = 1 + session.initialize = AsyncMock() + session.set_logging_level = AsyncMock() + sessions.append(session) + + session_context = AsyncMock() + session_context.__aenter__ = AsyncMock(return_value=session) + session_context.__aexit__ = AsyncMock(return_value=None) + session_contexts.append(session_context) + + with ( + patch.object(tool, "get_mcp_client", side_effect=transport_contexts), + patch("agent_framework._mcp.ClientSession", side_effect=session_contexts), + ): + await tool.connect() + + caplog.clear() + with caplog.at_level(logging.WARNING, logger=logger.name): + await asyncio.create_task(tool.connect(reset=True)) + + assert transport_contexts[0].closed_cleanly is True + assert transport_contexts[0].exit_task is transport_contexts[0].enter_task + assert transport_contexts[1].enter_task is transport_contexts[0].enter_task + assert tool.session is sessions[1] assert tool.is_connected is True + assert not any("cancel scope" in record.getMessage().lower() for record in caplog.records) + + await tool.close() + + +async def test_mcp_tool_connect_from_lifecycle_owner_bypasses_request_lock() -> None: + """connect(reset=True) should bypass the request queue when already on the owner task.""" + import asyncio + + tool = MCPStreamableHTTPTool( + name="test_server", + url="https://example.com/mcp", + load_tools=False, + load_prompts=False, + ) + + async def connect_from_owner_task() -> None: + tool._lifecycle_owner_task = asyncio.current_task() + try: + async with tool._lifecycle_request_lock: + await tool.connect(reset=True) + finally: + tool._lifecycle_owner_task = None + + with patch.object(tool, "_connect_on_owner", AsyncMock()) as mock_connect_on_owner: + await asyncio.wait_for(connect_from_owner_task(), timeout=0.1) + + mock_connect_on_owner.assert_awaited_once_with(reset=True) + + +async def test_mcp_tool_close_from_lifecycle_owner_bypasses_request_lock() -> None: + """close() should bypass the request queue when already on the owner task.""" + import asyncio + + tool = MCPStreamableHTTPTool( + name="test_server", + url="https://example.com/mcp", + load_tools=False, + load_prompts=False, + ) + + async def close_from_owner_task() -> None: + tool._lifecycle_owner_task = asyncio.current_task() + try: + async with tool._lifecycle_request_lock: + await tool.close() + finally: + tool._lifecycle_owner_task = None + + with patch.object(tool, "_close_on_owner", AsyncMock()) as mock_close_on_owner: + await asyncio.wait_for(close_from_owner_task(), timeout=0.1) + + mock_close_on_owner.assert_awaited_once_with() async def test_mcp_tool_safe_close_reraises_other_runtime_errors(): From bf0af178bde373d4edf809f9b63d7facaf232ed3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:33:08 -0700 Subject: [PATCH 13/25] .NET - Fix flaky workflows test (#4700) * Initial plan * Fix flaky test: initialize creationTime 1 second in the past Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> --- .../MessageMergerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index 704e25b14a..4181dad409 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -17,7 +17,7 @@ public class MessageMergerTests [Fact] public void Test_MessageMerger_AssemblesMessage() { - DateTimeOffset creationTime = DateTimeOffset.UtcNow; + DateTimeOffset creationTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1)); string responseId = Guid.NewGuid().ToString("N"); string messageId = Guid.NewGuid().ToString("N"); From 55011b725897eb6ee4d53571c8cd912bab461210 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:47:33 +0900 Subject: [PATCH 14/25] Python: Fix _deduplicate_messages catch-all branch dropping valid repeated messages (#4716) * Fix _deduplicate_messages catch-all branch dropping valid repeated messages (#4682) Remove the catch-all dedup branch that used (role, hash(content_str)) as a dedup key. This incorrectly treated any two messages with the same role and identical content as duplicates, dropping valid repeated messages (e.g., a user saying 'yes' to confirm two separate things). The tool-specific dedup branches (tool results by call_id, assistant tool calls by call_id tuple) remain unchanged as they correctly identify true protocol-level duplicates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: consecutive-duplicate detection for non-tool messages (#4682) - Replace blanket dedup removal with consecutive-duplicate detection: only skip a message if the immediately preceding message has the same role and content, preserving protection against upstream replays while allowing identical messages at different conversation points. - Strengthen test assertions to verify message identity and order, not just list length. - Add tests for consecutive duplicate skipping, non-consecutive preservation, and messages with contents=None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Use message_id for deduplication instead of content hashing Deduplicate general messages by message_id when available, replacing the consecutive-duplicate content check. Two messages with the same id are definitively the same message (upstream replay), while identical content with distinct ids (e.g. repeated "yes" confirmations) is preserved. Messages without a message_id are always kept. * Fix message_id dedup: truthy check, content-hash fallback, log safety - Use truthy check (`if msg.message_id`) instead of `is not None` so empty-string IDs fall through to content-hash dedup rather than collapsing unrelated messages. - Add content-hash fallback for messages without message_id, preventing false negatives from integrations that don't set IDs. - Remove raw message_id from log format string (addresses log-injection surface with control characters). - Add tests for empty-string message_id edge cases. - Update existing tests to reflect content-hash dedup behavior. Fixes #4682 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_message_adapters.py | 12 +- .../tests/ag_ui/test_message_adapters.py | 127 +++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 4a846ea41d..2e5294a6b6 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -242,8 +242,16 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]: unique_messages.append(msg) else: - content_str = str([str(c) for c in msg.contents]) if msg.contents else "" - key = (role_value, hash(content_str)) + # Use message_id for deduplication when available — two messages with the + # same id are definitively the same message (e.g. upstream replays), while + # different messages that happen to share identical content (e.g. repeated + # "yes" confirmations) will have distinct ids and be preserved. + # Fall back to content-hash when message_id is absent or empty. + if msg.message_id: + key = ("id", msg.message_id) + else: + content_str = str([str(c) for c in msg.contents]) if msg.contents else "" + key = ("content", role_value, hash(content_str)) if key in seen_keys: logger.info(f"Skipping duplicate message at index {idx}: role={role_value}") diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index 5227d376bb..cc4f1230df 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -1015,15 +1015,111 @@ def test_deduplicate_assistant_tool_calls(): assert len(result) == 1 -def test_deduplicate_general_messages(): - """Duplicate general user messages are deduplicated.""" +def test_deduplicate_by_message_id(): + """Messages with the same message_id are deduplicated.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msg1 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg1.message_id = "msg-1" + msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg2.message_id = "msg-1" + + result = _deduplicate_messages([msg1, msg2]) + assert len(result) == 1 + assert result == [msg1] + + +def test_deduplicate_preserves_repeated_confirmations_with_distinct_ids(): + """Identical content with different message_ids is preserved.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + assistant = Message(role="assistant", contents=[Content.from_text(text="Are you sure?")]) + assistant.message_id = "msg-1" + confirm1 = Message(role="user", contents=[Content.from_text(text="yes")]) + confirm1.message_id = "msg-2" + confirm2 = Message(role="user", contents=[Content.from_text(text="yes")]) + confirm2.message_id = "msg-3" + + result = _deduplicate_messages([confirm1, assistant, confirm2]) + assert result == [confirm1, assistant, confirm2] + + +def test_deduplicate_preserves_repeated_system_messages_with_distinct_ids(): + """Non-consecutive identical system messages with different ids are preserved.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + sys1 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]) + sys1.message_id = "msg-1" + user_msg = Message(role="user", contents=[Content.from_text(text="Hi")]) + user_msg.message_id = "msg-2" + sys2 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]) + sys2.message_id = "msg-3" + + result = _deduplicate_messages([sys1, user_msg, sys2]) + assert result == [sys1, user_msg, sys2] + + +def test_deduplicate_skips_replayed_system_messages_with_same_id(): + """System messages replayed with the same message_id are deduplicated.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msgs = [] + for _ in range(3): + m = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]) + m.message_id = "msg-1" + msgs.append(m) + + result = _deduplicate_messages(msgs) + assert len(result) == 1 + + +def test_deduplicate_without_message_id_uses_content_hash(): + """Messages without message_id are deduplicated by content hash.""" from agent_framework_ag_ui._message_adapters import _deduplicate_messages msg1 = Message(role="user", contents=[Content.from_text(text="Hello")]) msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) result = _deduplicate_messages([msg1, msg2]) - assert len(result) == 1 + assert result == [msg1] + + +def test_deduplicate_without_message_id_preserves_different_content(): + """Messages without message_id but different content are preserved.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msg1 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg2 = Message(role="user", contents=[Content.from_text(text="World")]) + + result = _deduplicate_messages([msg1, msg2]) + assert result == [msg1, msg2] + + +def test_deduplicate_handles_none_contents(): + """Messages with contents=None pass through without errors; duplicates are deduped.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msg1 = Message(role="user", contents=None) + msg2 = Message(role="assistant", contents=[Content.from_text(text="Hello")]) + msg3 = Message(role="user", contents=None) + + result = _deduplicate_messages([msg1, msg2, msg3]) + assert result == [msg1, msg2] + + +def test_deduplicate_mixed_id_and_no_id(): + """Messages with and without message_id coexist correctly.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msg1 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg1.message_id = "msg-1" + msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) # no id + msg3 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg3.message_id = "msg-1" # duplicate of msg1 + + result = _deduplicate_messages([msg1, msg2, msg3]) + assert len(result) == 2 + assert result == [msg1, msg2] def test_deduplicate_replaces_empty_tool_result(): @@ -1038,7 +1134,30 @@ def test_deduplicate_replaces_empty_tool_result(): assert result[0].contents[0].result == "actual result" -# ── Multimodal & content conversion edge cases ── +def test_deduplicate_empty_string_message_id_falls_back_to_content_hash(): + """Empty-string message_id is treated as missing; content-hash dedup is used.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msg1 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg1.message_id = "" + msg2 = Message(role="user", contents=[Content.from_text(text="World")]) + msg2.message_id = "" + + result = _deduplicate_messages([msg1, msg2]) + assert result == [msg1, msg2], "Different content with empty IDs should both be preserved" + + +def test_deduplicate_empty_string_message_id_deduplicates_same_content(): + """Empty-string message_id with identical content should be deduplicated.""" + from agent_framework_ag_ui._message_adapters import _deduplicate_messages + + msg1 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg1.message_id = "" + msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) + msg2.message_id = "" + + result = _deduplicate_messages([msg1, msg2]) + assert result == [msg1], "Same content with empty IDs should be deduplicated" def test_convert_agui_content_unknown_source_type_fallback(): From 414496dda7efeb671abef8ab3415bcc941bbeef8 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:34:21 -0700 Subject: [PATCH 15/25] fix: Azure Redis sample missing session for history persistence (#4692) --- .../redis/azure_redis_conversation.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index 6408fd4be4..5adbb53ef3 100644 --- a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -3,7 +3,11 @@ """Azure Managed Redis History Provider with Azure AD Authentication This example demonstrates how to use Azure Managed Redis with Azure AD authentication -to persist conversational details using RedisHistoryProvider. +to persist conversation history using RedisHistoryProvider. + +Key concepts: + - RedisHistoryProvider = durable storage (where messages are persisted) + - AgentSession = conversation identity (which conversation the messages belong to) Requirements: - Azure Managed Redis instance with Azure AD authentication enabled @@ -61,11 +65,11 @@ async def main() -> None: print("Get your Object ID from the Azure Portal") return - # Create Azure CLI credential provider (uses 'az login' credentials) + # 1. Create Azure CLI credential provider (uses 'az login' credentials) azure_credential = AsyncAzureCliCredential() credential_provider = AzureCredentialProvider(azure_credential, user_object_id) - # Create Azure Redis history provider + # 2. Create Azure Redis history provider (the durable storage backend) history_provider = RedisHistoryProvider( source_id="redis_memory", credential_provider=credential_provider, @@ -76,49 +80,54 @@ async def main() -> None: max_messages=100, ) - # Create chat client + # 3. Create chat client client = AzureOpenAIResponsesClient( project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - # Create agent with Azure Redis history provider + # 4. Create agent with Azure Redis history provider agent = client.as_agent( name="AzureRedisAssistant", instructions="You are a helpful assistant.", context_providers=[history_provider], ) - # Conversation + # 5. Create a session to provide conversation identity. + # The session ID is used as the Redis key — all runs sharing the same session + # will read/write the same conversation history in Redis. + session = agent.create_session() + + # 6. Conversation — each run passes the same session for continuity query = "Remember that I enjoy gumbo" - result = await agent.run(query) + result = await agent.run(query, session=session) print("User: ", query) print("Agent: ", result) # Ask the agent to recall the stored preference; it should retrieve from memory query = "What do I enjoy?" - result = await agent.run(query) + result = await agent.run(query, session=session) print("User: ", query) print("Agent: ", result) query = "What did I say to you just now?" - result = await agent.run(query) + result = await agent.run(query, session=session) print("User: ", query) print("Agent: ", result) query = "Remember that I have a meeting at 3pm tomorrow" - result = await agent.run(query) + result = await agent.run(query, session=session) print("User: ", query) print("Agent: ", result) query = "Tulips are red" - result = await agent.run(query) + result = await agent.run(query, session=session) print("User: ", query) print("Agent: ", result) query = "What was the first thing I said to you this conversation?" - result = await agent.run(query) + result = await agent.run(query, session=session) print("User: ", query) print("Agent: ", result) From 0fdcfd0f4cabf99558faa3ed8dde6ea75aed5bdd Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 16 Mar 2026 22:41:31 +0100 Subject: [PATCH 16/25] Python: preserve A2A message context_id (#4686) * Python: forward A2A context_id * Avoid duplicating A2A context ids Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/a2a/agent_framework_a2a/_agent.py | 3 ++- python/packages/a2a/tests/test_a2a_agent.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index c954c90fc0..e11aa668da 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -486,13 +486,14 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): raise ValueError(f"Unknown content type: {content.type}") # Exclude framework-internal keys (e.g. attribution) from wire metadata - internal_keys = {"_attribution"} + internal_keys = {"_attribution", "context_id"} metadata = {k: v for k, v in message.additional_properties.items() if k not in internal_keys} or None return A2AMessage( role=A2ARole("user"), parts=parts, message_id=message.message_id or uuid.uuid4().hex, + context_id=message.additional_properties.get("context_id"), metadata=metadata, ) diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index ce7bb42a48..a426c27a7f 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -507,6 +507,23 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None: assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing) +def test_prepare_message_for_a2a_forwards_context_id() -> None: + """Test conversion of Message preserves context_id without duplicating it in metadata.""" + + agent = A2AAgent(client=MagicMock(), _http_client=None) + + message = Message( + role="user", + contents=[Content.from_text(text="Continue the task")], + additional_properties={"context_id": "ctx-123", "trace_id": "trace-456"}, + ) + + result = agent._prepare_message_for_a2a(message) + + assert result.context_id == "ctx-123" + assert result.metadata == {"trace_id": "trace-456"} + + def test_parse_contents_from_a2a_with_data_part() -> None: """Test conversion of A2A DataPart.""" From cbcdb2d29e7007aa96e1a40e6d71aee9c4d5e1ca Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 16 Mar 2026 16:00:50 -0700 Subject: [PATCH 17/25] .NET: Add durable workflow support (#4436) * .NET: [Feature Branch] Add basic durable workflow support (#3648) * Add basic durable workflow support. * PR feedback fixes * Add conditional edge sample. * PR feedback fixes. * Minor cleanup. * Minor cleanup * Minor formatting improvements. * Improve comments/documentation on the execution flow. * .NET: [Feature Branch] Add Azure Functions hosting support for durable workflows (#3935) * Adding azure functions workflow support. * - PR feedback fixes. - Add example to demonstrate complex Object as payload. * rename instanceId to runId. * Use custom ITaskOrchestrator to run orchestrator function. * .NET: [Feature Branch] Adding support for events & shared state in durable workflows (#4020) * Adding support for events & shared state in durable workflows. * PR feedback fixes * PR feedback fixes. * Add YieldOutputAsync calls to 05_WorkflowEvents sample executors The integration test asserts that WorkflowOutputEvent is found in the stream, but the sample executors only used AddEventAsync for custom events and never called YieldOutputAsync. Since WorkflowOutputEvent is only emitted via explicit YieldOutputAsync calls, the assertion would fail. Added YieldOutputAsync to each executor to match the test expectation and demonstrate the API in the sample. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deserialization to use shared serializer options. * PR feedback updates. * Sample cleanup * PR feedback fixes * Addressing PR review feedback for DurableStreamingWorkflowRun - Use -1 instead of 0 for taskId in TaskFailedException when task ID is not relevant. - Add [NotNullWhen(true)] to TryParseWorkflowResult out parameter following .NET TryXXX conventions. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: [Feature Branch] Add nested sub-workflow support for durable workflows (#4190) * .NET: [Feature Branch] Add nested sub-workflow support for durable workflows * fix readme path * Switch Orchestration output from string to DurableWorkflowResult. * PR feedback fixes * Minor cleanup based on PR feedback. * .NET: [Feature Branch] Add Human In the Loop support for durable workflows (#4358) * Add Azure Functions HITL workflow sample Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop workflow support with HTTP endpoints for status checking and approval responses. The sample includes: - ExpenseReimbursement workflow with RequestPort for manager approval - Custom HTTP endpoint to check workflow status and pending approvals - Custom HTTP endpoint to send approval responses via RaiseEventAsync - demo.http file with step-by-step interaction examples * PR feedback fixes * Minor comment cleanup * Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup * fix for PR feedback * PR feedback updates * Improvements to samples * Improvements to README * Update samples to use parallel request ports. * Unit tests * Introduce local variables to improve readability of Workflows.Workflows access patter * Use GitHub-style callouts and add PowerShell command variants in HITL sample README * Add changelog entries for durable workflow support (#4436) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump Microsoft.DurableTask.Worker to 1.19.1 to fix version downgrade Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.13.1 requires Microsoft.DurableTask.Worker >= 1.19.1 via its transitive dependency on Microsoft.DurableTask.Worker.Grpc 1.19.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix broken markdown links in durable workflow sample READMEs - Create Workflow/README.md with environment setup docs - Fix ../README.md -> ../../README.md in ConsoleApps 01, 02, 03, 08 - Fix SubWorkflows relative path (3 levels -> 4 levels up) - Fix dead Durable Task Scheduler URL Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build errors from main merge: Throw conflict, ExecuteAsync rename, GetNewSessionAsync rename - Remove InjectSharedThrow from DurableTask csproj (uses Workflows' internal Throw via InternalsVisibleTo) - Update ExecuteAsync -> ExecuteCoreAsync with WorkflowTelemetryContext.Disabled - Update GetNewSessionAsync -> CreateSessionAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move durable workflow samples to 04-hosting/DurableWorkflows Aligns with main branch sample reorganization where durable samples live under 04-hosting/ (alongside DurableAgents/). - Move samples/Durable/Workflow/ -> samples/04-hosting/DurableWorkflows/ - Add Directory.Build.props matching DurableAgents pattern - Update slnx project paths - Update integration test sample paths - Update README cd paths and cross-references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build errors: remove duplicate base class members, update renamed APIs - Remove duplicate OutputLog, WriteInputAsync, CreateTestTimeoutCts, etc. from ConsoleAppSamplesValidation (already in SamplesValidationBase) - Update AddFanInEdge -> AddFanInBarrierEdge in workflow samples - Update GetNewSessionAsync -> CreateSessionAsync in workflow samples - Update SourceId -> ExecutorId (obsolete) in workflow samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix dotnet format issues: add UTF-8 BOM and remove unused using - Add UTF-8 BOM to 20 .cs files across DurableTask, AzureFunctions, unit tests, and workflow samples - Remove unnecessary using directive in 07_SubWorkflows/Executors.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix typo PaymentProcesser -> PaymentProcessor and garbled arrows in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix GetExecutorName to handle agent names with underscores Split on last underscore instead of first, and validate that the suffix is a 32-char hex string (sanitized GUID) before stripping it. This prevents truncation of agent names like 'my_agent' when the executor ID is 'my_agent_'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align DurableTask.Client.AzureManaged to 1.19.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump DurableTask and Azure Functions extension package versions - DurableTask.* packages: 1.19.1 -> 1.22.0 - Functions.Worker.Extensions.DurableTask: 1.13.1 -> 1.16.0 - Functions.Worker.Extensions.DurableTask.AzureManaged: 1.0.1 -> 1.5.0 (telemetry bug fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump DurableTask SDK packages to 1.22.0 - DurableTask.Client: 1.19.1 -> 1.22.0 - DurableTask.Client.AzureManaged: 1.19.1 -> 1.22.0 - DurableTask.Worker: 1.19.1 -> 1.22.0 - DurableTask.Worker.AzureManaged: 1.19.1 -> 1.22.0 - Azure Functions extensions kept at original versions (1.13.1/1.0.1) due to host-side DurableTask.Core 3.7.0 incompatibility with newer extensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Microsoft.Azure.Functions.Worker.Extensions.DurableTask to "1.16.0" * Add the local.settings.json files to the sample which were previously ignored. This aligns with our other samples. * Increase timeout for tests as CI has them failing transiently. * increaset timeout value for azure functions integration tests. * Add YieldsOutput(string) to workflow shared state sample executors ValidateOrder and EnrichOrder call YieldOutputAsync with string messages, but only their TOutput (OrderDetails) was in the allowed yield types. This caused TargetInvocationException in the WorkflowSharedState sample validation integration test. * Downgrade the durable packages to 1.18.0 * Downgrading Worker.Extensions.DurableTask to 1.12.1 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- dotnet/agent-framework-dotnet.slnx | 21 +- .../01_SequentialWorkflow.csproj | 42 + .../OrderCancelExecutors.cs | 215 +++++ .../01_SequentialWorkflow/Program.cs | 52 ++ .../01_SequentialWorkflow/README.md | 100 +++ .../01_SequentialWorkflow/demo.http | 26 + .../01_SequentialWorkflow/host.json | 20 + .../01_SequentialWorkflow/local.settings.json | 10 + .../02_ConcurrentWorkflow.csproj | 42 + .../02_ConcurrentWorkflow/ExpertExecutors.cs | 73 ++ .../02_ConcurrentWorkflow/Program.cs | 45 ++ .../02_ConcurrentWorkflow/README.md | 90 +++ .../02_ConcurrentWorkflow/demo.http | 14 + .../02_ConcurrentWorkflow/host.json | 20 + .../02_ConcurrentWorkflow/local.settings.json | 10 + .../03_WorkflowHITL/03_WorkflowHITL.csproj | 43 + .../03_WorkflowHITL/Executors.cs | 63 ++ .../AzureFunctions/03_WorkflowHITL/Program.cs | 51 ++ .../AzureFunctions/03_WorkflowHITL/README.md | 266 ++++++ .../AzureFunctions/03_WorkflowHITL/demo.http | 53 ++ .../AzureFunctions/03_WorkflowHITL/host.json | 20 + .../03_WorkflowHITL/local.settings.json | 10 + .../01_SequentialWorkflow.csproj | 29 + .../OrderCancelExecutors.cs | 116 +++ .../01_SequentialWorkflow/Program.cs | 93 +++ .../01_SequentialWorkflow/README.md | 83 ++ .../02_ConcurrentWorkflow.csproj | 30 + .../02_ConcurrentWorkflow/ExpertExecutors.cs | 73 ++ .../02_ConcurrentWorkflow/Program.cs | 114 +++ .../02_ConcurrentWorkflow/README.md | 100 +++ .../03_ConditionalEdges.csproj | 29 + .../03_ConditionalEdges/NotifyFraud.cs | 85 ++ .../03_ConditionalEdges/Program.cs | 97 +++ .../ConsoleApps/03_ConditionalEdges/README.md | 92 +++ .../04_WorkflowAndAgents.csproj | 30 + .../ParseQuestionExecutor.cs | 73 ++ .../04_WorkflowAndAgents/Program.cs | 133 +++ .../05_WorkflowEvents.csproj | 28 + .../05_WorkflowEvents/Executors.cs | 129 +++ .../ConsoleApps/05_WorkflowEvents/Program.cs | 138 ++++ .../ConsoleApps/05_WorkflowEvents/README.md | 127 +++ .../06_WorkflowSharedState.csproj | 29 + .../06_WorkflowSharedState/Executors.cs | 184 +++++ .../06_WorkflowSharedState/Program.cs | 117 +++ .../06_WorkflowSharedState/README.md | 71 ++ .../07_SubWorkflows/07_SubWorkflows.csproj | 28 + .../ConsoleApps/07_SubWorkflows/Executors.cs | 232 ++++++ .../ConsoleApps/07_SubWorkflows/Program.cs | 146 ++++ .../ConsoleApps/07_SubWorkflows/README.md | 105 +++ .../08_WorkflowHITL/08_WorkflowHITL.csproj | 28 + .../ConsoleApps/08_WorkflowHITL/Executors.cs | 81 ++ .../ConsoleApps/08_WorkflowHITL/Program.cs | 98 +++ .../ConsoleApps/08_WorkflowHITL/README.md | 106 +++ .../DurableWorkflows/Directory.Build.props | 5 + .../04-hosting/DurableWorkflows/README.md | 50 ++ .../CHANGELOG.md | 40 +- .../DurableAgentsOptions.cs | 11 + .../DurableDataConverter.cs | 66 ++ .../DurableOptions.cs | 31 + .../DurableServicesMarker.cs | 34 + .../Microsoft.Agents.AI.DurableTask/Logs.cs | 127 +++ .../Microsoft.Agents.AI.DurableTask.csproj | 2 +- .../ServiceCollectionExtensions.cs | 413 +++++++--- .../Workflows/DurableActivityExecutor.cs | 177 ++++ .../Workflows/DurableActivityInput.cs | 24 + .../Workflows/DurableExecutorDispatcher.cs | 216 +++++ .../Workflows/DurableExecutorOutput.cs | 39 + .../Workflows/DurableHaltRequestedEvent.cs | 25 + .../Workflows/DurableMessageEnvelope.cs | 51 ++ .../Workflows/DurableRunStatus.cs | 49 ++ .../Workflows/DurableSerialization.cs | 22 + .../Workflows/DurableStreamingWorkflowRun.cs | 452 +++++++++++ .../Workflows/DurableWorkflowClient.cs | 95 +++ .../DurableWorkflowCompletedEvent.cs | 27 + .../Workflows/DurableWorkflowContext.cs | 327 ++++++++ .../Workflows/DurableWorkflowFailedEvent.cs | 35 + .../Workflows/DurableWorkflowInput.cs | 16 + .../Workflows/DurableWorkflowJsonContext.cs | 41 + .../Workflows/DurableWorkflowLiveStatus.cs | 59 ++ .../Workflows/DurableWorkflowOptions.cs | 111 +++ .../Workflows/DurableWorkflowResult.cs | 42 + .../Workflows/DurableWorkflowRun.cs | 116 +++ .../Workflows/DurableWorkflowRunner.cs | 619 ++++++++++++++ .../DurableWorkflowWaitingForInputEvent.cs | 42 + .../EdgeRouters/DurableDirectEdgeRouter.cs | 156 ++++ .../Workflows/EdgeRouters/DurableEdgeMap.cs | 205 +++++ .../EdgeRouters/DurableFanOutEdgeRouter.cs | 67 ++ .../EdgeRouters/IDurableEdgeRouter.cs | 26 + .../Workflows/ExecutorRegistry.cs | 83 ++ .../Workflows/IAwaitableWorkflowRun.cs | 34 + .../Workflows/IStreamingWorkflowRun.cs | 55 ++ .../Workflows/IWorkflowClient.cs | 71 ++ .../Workflows/IWorkflowRun.cs | 39 + .../Workflows/PendingRequestPortStatus.cs | 12 + .../Workflows/TypedPayload.cs | 20 + .../Workflows/WorkflowAnalyzer.cs | 245 ++++++ .../Workflows/WorkflowExecutorInfo.cs | 29 + .../Workflows/WorkflowGraphInfo.cs | 98 +++ .../Workflows/WorkflowNamingHelper.cs | 113 +++ .../BuiltInFunctionExecutor.cs | 103 ++- .../BuiltInFunctions.cs | 209 +++++ .../CHANGELOG.md | 6 +- ...DurableAgentFunctionMetadataTransformer.cs | 44 +- .../FunctionMetadataFactory.cs | 101 +++ .../FunctionsApplicationBuilderExtensions.cs | 87 ++ .../FunctionsDurableOptions.cs | 29 + .../Logs.cs | 12 + ...ft.Agents.AI.Hosting.AzureFunctions.csproj | 3 +- .../DurableWorkflowOptionsExtensions.cs | 30 + ...bleWorkflowsFunctionMetadataTransformer.cs | 152 ++++ .../Workflows/WorkflowOrchestrator.cs | 51 ++ .../Microsoft.Agents.AI.Workflows.csproj | 1 + .../ConsoleAppSamplesValidation.cs | 452 +---------- .../SamplesValidationBase.cs | 451 +++++++++++ .../WorkflowConsoleAppSamplesValidation.cs | 566 +++++++++++++ ...oft.Agents.AI.DurableTask.UnitTests.csproj | 1 + .../Workflows/DurableActivityExecutorTests.cs | 235 ++++++ .../DurableStreamingWorkflowRunTests.cs | 765 ++++++++++++++++++ .../Workflows/DurableWorkflowContextTests.cs | 504 ++++++++++++ .../Workflows/WorkflowNamingHelperTests.cs | 90 +++ .../SamplesValidation.cs | 6 +- .../WorkflowSamplesValidation.cs | 587 ++++++++++++++ 123 files changed, 12611 insertions(+), 603 deletions(-) create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props create mode 100644 dotnet/samples/04-hosting/DurableWorkflows/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 037e61ab3d..d322b4d679 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -127,7 +127,7 @@ - + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 04fbb6cd87..576d2c5c54 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -61,6 +61,25 @@ + + + + + + + + + + + + + + + + + + + @@ -520,4 +539,4 @@ - \ No newline at end of file + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj new file mode 100644 index 0000000000..0c0e4f7fe0 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj @@ -0,0 +1,42 @@ + + + net10.0 + v4 + Exe + enable + enable + + SingleAgent + SingleAgent + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs new file mode 100644 index 0000000000..6d86bfe757 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderCancelExecutors.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace SequentialWorkflow; + +/// +/// Looks up an order by its ID and return an Order object. +/// +internal sealed class OrderLookup() : Executor("OrderLookup") +{ + public override async ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message}'"); + Console.ResetColor(); + + // Simulate database lookup with delay + await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken); + + Order order = new( + Id: message, + OrderDate: DateTime.UtcNow.AddDays(-1), + IsCancelled: false, + Customer: new Customer(Name: "Jerry", Email: "jerry@example.com")); + + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message}' for customer '{order.Customer.Name}'"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return order; + } +} + +/// +/// Cancels an order. +/// +internal sealed class OrderCancel() : Executor("OrderCancel") +{ + public override async ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'"); + Console.ResetColor(); + + // Simulate a slow cancellation process (e.g., calling external payment system) + for (int i = 1; i <= 3; i++) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine("│ [Activity] OrderCancel: Processing..."); + Console.ResetColor(); + } + + Order cancelledOrder = message with { IsCancelled = true }; + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return cancelledOrder; + } +} + +/// +/// Sends a cancellation confirmation email to the customer. +/// +internal sealed class SendEmail() : Executor("SendEmail") +{ + public override ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'..."); + Console.ResetColor(); + + string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}."; + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(result); + } +} + +internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, Customer Customer); + +internal sealed record Customer(string Name, string Email); + +/// +/// Represents a batch cancellation request with multiple order IDs and a reason. +/// This demonstrates using a complex typed object as workflow input. +/// +#pragma warning disable CA1812 // Instantiated via JSON deserialization at runtime +internal sealed record BatchCancelRequest(string[] OrderIds, string Reason, bool NotifyCustomers); +#pragma warning restore CA1812 + +/// +/// Represents the result of processing a batch cancellation. +/// +internal sealed record BatchCancelResult(int TotalOrders, int CancelledCount, string Reason); + +/// +/// Generates a status report for an order. +/// +internal sealed class StatusReport() : Executor("StatusReport") +{ + public override ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] StatusReport: Generating report for order '{message.Id}'"); + Console.ResetColor(); + + string status = message.IsCancelled ? "Cancelled" : "Active"; + string result = $"Order {message.Id} for {message.Customer.Name}: Status={status}, Date={message.OrderDate:yyyy-MM-dd}"; + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"│ [Activity] StatusReport: ✓ {result}"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(result); + } +} + +/// +/// Processes a batch cancellation request. Accepts a complex object +/// as input, demonstrating how workflows can receive structured JSON input. +/// +internal sealed class BatchCancelProcessor() : Executor("BatchCancelProcessor") +{ + public override async ValueTask HandleAsync( + BatchCancelRequest message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] BatchCancelProcessor: Processing {message.OrderIds.Length} orders"); + Console.WriteLine($"│ [Activity] BatchCancelProcessor: Reason: {message.Reason}"); + Console.WriteLine($"│ [Activity] BatchCancelProcessor: Notify customers: {message.NotifyCustomers}"); + Console.ResetColor(); + + // Simulate processing each order + int cancelledCount = 0; + foreach (string orderId in message.OrderIds) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + cancelledCount++; + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Cancelled order '{orderId}'"); + Console.ResetColor(); + } + + BatchCancelResult result = new(message.OrderIds.Length, cancelledCount, message.Reason); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Batch complete: {cancelledCount}/{message.OrderIds.Length} cancelled"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return result; + } +} + +/// +/// Generates a summary of the batch cancellation. +/// +internal sealed class BatchCancelSummary() : Executor("BatchCancelSummary") +{ + public override ValueTask HandleAsync( + BatchCancelResult message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ [Activity] BatchCancelSummary: Generating summary"); + Console.ResetColor(); + + string result = $"Batch cancellation complete: {message.CancelledCount}/{message.TotalOrders} orders cancelled. Reason: {message.Reason}"; + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"│ [Activity] BatchCancelSummary: ✓ {result}"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(result); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs new file mode 100644 index 0000000000..20da58d1a1 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates three workflows that share executors. +// The CancelOrder workflow cancels an order and notifies the customer. +// The OrderStatus workflow looks up an order and generates a status report. +// The BatchCancelOrders workflow accepts a complex JSON input to cancel multiple orders. +// Both CancelOrder and OrderStatus reuse the same OrderLookup executor, demonstrating executor sharing. + +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using SequentialWorkflow; + +// Define executors for all workflows +OrderLookup orderLookup = new(); +OrderCancel orderCancel = new(); +SendEmail sendEmail = new(); +StatusReport statusReport = new(); +BatchCancelProcessor batchCancelProcessor = new(); +BatchCancelSummary batchCancelSummary = new(); + +// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail +Workflow cancelOrder = new WorkflowBuilder(orderLookup) + .WithName("CancelOrder") + .WithDescription("Cancel an order and notify the customer") + .AddEdge(orderLookup, orderCancel) + .AddEdge(orderCancel, sendEmail) + .Build(); + +// Build the OrderStatus workflow: OrderLookup -> StatusReport +// This workflow shares the OrderLookup executor with the CancelOrder workflow. +Workflow orderStatus = new WorkflowBuilder(orderLookup) + .WithName("OrderStatus") + .WithDescription("Look up an order and generate a status report") + .AddEdge(orderLookup, statusReport) + .Build(); + +// Build the BatchCancelOrders workflow: BatchCancelProcessor -> BatchCancelSummary +// This workflow demonstrates using a complex JSON object as the workflow input. +Workflow batchCancelOrders = new WorkflowBuilder(batchCancelProcessor) + .WithName("BatchCancelOrders") + .WithDescription("Cancel multiple orders in a batch using a complex JSON input") + .AddEdge(batchCancelProcessor, batchCancelSummary) + .Build(); + +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(cancelOrder, orderStatus, batchCancelOrders)) + .Build(); +app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md new file mode 100644 index 0000000000..384fd358a7 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md @@ -0,0 +1,100 @@ +# Sequential Workflow Sample + +This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that hosts durable workflows with sequential executor chains. It showcases two workflows that share a common executor, demonstrating executor reuse across workflows. + +## Key Concepts Demonstrated + +- Defining workflows with sequential executor chains using `WorkflowBuilder` +- Sharing executors across multiple workflows (the `OrderLookup` executor is used by both workflows) +- Registering workflows with the Function app using `ConfigureDurableWorkflows` +- Durable orchestration ensuring workflows survive process restarts and failures +- Starting workflows via HTTP requests +- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard + +## Workflows + +This sample defines two workflows: + +1. **CancelOrder**: `OrderLookup` → `OrderCancel` → `SendEmail` — Looks up an order, cancels it, and sends a confirmation email. +2. **OrderStatus**: `OrderLookup` → `StatusReport` — Looks up an order and generates a status report. + +Both workflows share the `OrderLookup` executor, which is registered only once by the framework. + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. + +## Running the Sample + +With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints. + +You can use the `demo.http` file to trigger the workflows, or a command line tool like `curl` as shown below: + +### Cancel an Order + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -d "12345" +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/CancelOrder/run ` + -ContentType text/plain ` + -Body "12345" +``` + +The response will confirm the workflow orchestration has started: + +```text +Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456 +``` + +> **Tip:** You can provide a custom run ID by appending a `runId` query parameter: +> +> ```bash +> curl -X POST "http://localhost:7071/api/workflows/CancelOrder/run?runId=my-order-123" \ +> -H "Content-Type: text/plain" \ +> -d "12345" +> ``` +> +> If not provided, a unique run ID is auto-generated. + +In the function app logs, you will see the sequential execution of each executor: + +```text +│ [Activity] OrderLookup: Starting lookup for order '12345' +│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry' +│ [Activity] OrderCancel: Starting cancellation for order '12345' +│ [Activity] OrderCancel: ✓ Order '12345' has been cancelled +│ [Activity] SendEmail: Sending email to 'jerry@example.com'... +│ [Activity] SendEmail: ✓ Email sent successfully! +``` + +### Get Order Status + +```bash +curl -X POST http://localhost:7071/api/workflows/OrderStatus/run \ + -H "Content-Type: text/plain" \ + -d "12345" +``` + +The `OrderStatus` workflow reuses the same `OrderLookup` executor and then generates a status report: + +```text +│ [Activity] OrderLookup: Starting lookup for order '12345' +│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry' +│ [Activity] StatusReport: Generating report for order '12345' +│ [Activity] StatusReport: ✓ Order 12345 for Jerry: Status=Active, Date=2025-01-01 +``` + +### Viewing Workflows in the DTS Dashboard + +After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http new file mode 100644 index 0000000000..8366216a6c --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http @@ -0,0 +1,26 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Cancel an order +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain + +12345 + +### Cancel an order with a custom run ID +POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123 +Content-Type: text/plain + +99999 + +### Get order status (shares OrderLookup executor with CancelOrder) +POST {{authority}}/api/workflows/OrderStatus/run +Content-Type: text/plain + +12345 + +### Batch cancel orders with a complex JSON input +POST {{authority}}/api/workflows/BatchCancelOrders/run +Content-Type: application/json + +{"orderIds": ["1001", "1002", "1003"], "reason": "Customer requested cancellation", "notifyCustomers": true} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json new file mode 100644 index 0000000000..5f6d7d3340 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT_NAME": "" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj new file mode 100644 index 0000000000..0c0e4f7fe0 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj @@ -0,0 +1,42 @@ + + + net10.0 + v4 + Exe + enable + enable + + SingleAgent + SingleAgent + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs new file mode 100644 index 0000000000..40674126f6 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/ExpertExecutors.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowConcurrency; + +/// +/// Parses and validates the incoming question before sending to AI agents. +/// +internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents..."); + + string formattedQuestion = message.Trim(); + if (!formattedQuestion.EndsWith('?')) + { + formattedQuestion += "?"; + } + + Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\""); + Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL..."); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(formattedQuestion); + } +} + +/// +/// Aggregates responses from all AI agents into a comprehensive answer. +/// This is the Fan-in point where parallel results are collected. +/// +internal sealed class AggregatorExecutor() : Executor("Aggregator") +{ + public override ValueTask HandleAsync( + string[] message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses"); + Console.WriteLine("│ [Aggregator] Combining into comprehensive answer..."); + Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" + + " AI EXPERT PANEL RESPONSES\n" + + "═══════════════════════════════════════════════════════════════\n\n"; + + for (int i = 0; i < message.Length; i++) + { + string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST"; + aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; + } + + aggregatedResult += "═══════════════════════════════════════════════════════════════\n" + + $"Summary: Received perspectives from {message.Length} AI experts.\n" + + "═══════════════════════════════════════════════════════════════"; + + return ValueTask.FromResult(aggregatedResult); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs new file mode 100644 index 0000000000..6532009d4b --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/Program.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using OpenAI.Chat; +using WorkflowConcurrency; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set."); +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); + +// Create Azure OpenAI client +AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); +ChatClient chatClient = openAiClient.GetChatClient(deploymentName); + +// Define the 4 executors for the workflow +ParseQuestionExecutor parseQuestion = new(); +AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist"); +AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist"); +AggregatorExecutor aggregator = new(); + +// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator +Workflow workflow = new WorkflowBuilder(parseQuestion) + .WithName("ExpertReview") + .AddFanOutEdge(parseQuestion, [physicist, chemist]) + .AddFanInBarrierEdge([physicist, chemist], aggregator) + .Build(); + +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(workflow)) + .Build(); +app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md new file mode 100644 index 0000000000..73230ff048 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/README.md @@ -0,0 +1,90 @@ +# Concurrent Workflow Sample + +This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that orchestrates concurrent execution of multiple AI agents using the fan-out/fan-in pattern within a durable workflow. + +## Key Concepts Demonstrated + +- Defining workflows with fan-out/fan-in edges for parallel execution using `WorkflowBuilder` +- Mixing custom executors with AI agents in a single workflow +- Concurrent execution of multiple AI agents (physics and chemistry experts) +- Response aggregation from parallel branches into a unified result +- Durable orchestration with automatic checkpointing and resumption from failures +- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard + +## Workflow + +This sample defines a single workflow: + +**ExpertReview**: `ParseQuestion` → [`Physicist`, `Chemist`] (parallel) → `Aggregator` + +1. **ParseQuestion** — A custom executor that validates and formats the incoming question. +2. **Physicist** and **Chemist** — AI agents that run concurrently, each providing an expert perspective. +3. **Aggregator** — A custom executor that combines the parallel responses into a comprehensive answer. + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. + +This sample requires Azure OpenAI. Set the following environment variables: + +- `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint URL. +- `AZURE_OPENAI_DEPLOYMENT` — The name of your chat model deployment. +- `AZURE_OPENAI_KEY` (optional) — Your Azure OpenAI API key. If not set, Azure CLI credentials are used. + +## Running the Sample + +With the environment setup and function app running, you can test the sample by sending an HTTP request with a science question to the workflow endpoint. + +You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below: + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpertReview/run \ + -H "Content-Type: text/plain" \ + -d "What is temperature?" +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpertReview/run ` + -ContentType text/plain ` + -Body "What is temperature?" +``` + +The response will confirm the workflow orchestration has started: + +```text +Workflow orchestration started for ExpertReview. Orchestration runId: abc123def456 +``` + +> **Tip:** You can provide a custom run ID by appending a `runId` query parameter: +> +> ```bash +> curl -X POST "http://localhost:7071/api/workflows/ExpertReview/run?runId=my-review-123" \ +> -H "Content-Type: text/plain" \ +> -d "What is temperature?" +> ``` +> +> If not provided, a unique run ID is auto-generated. + +In the function app logs, you will see the fan-out/fan-in execution pattern: + +```text +│ [ParseQuestion] Preparing question for AI agents... +│ [ParseQuestion] Question: "What is temperature?" +│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL... +│ [Aggregator] 📋 Received 2 AI agent responses +│ [Aggregator] Combining into comprehensive answer... +│ [Aggregator] ✓ Aggregation complete! +``` + +The Physicist and Chemist AI agents execute concurrently, and the Aggregator combines their responses into a formatted expert panel result. + +### Viewing Workflows in the DTS Dashboard + +After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http new file mode 100644 index 0000000000..1a9e563126 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/demo.http @@ -0,0 +1,14 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Prompt the agent +POST {{authority}}/api/workflows/ExpertReview/run +Content-Type: text/plain + +What is temperature? + +### Start with a custom run ID +POST {{authority}}/api/workflows/ExpertReview/run?runId=my-review-123 +Content-Type: text/plain + +What is gravity? diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json new file mode 100644 index 0000000000..5f6d7d3340 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT_NAME": "" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj new file mode 100644 index 0000000000..c569deacd0 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj @@ -0,0 +1,43 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowHITLFunctions + WorkflowHITLFunctions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs new file mode 100644 index 0000000000..c299ee2cd5 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Executors.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowHITLFunctions; + +/// Expense approval request passed to the RequestPort. +public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName); + +/// Approval response received from the RequestPort. +public record ApprovalResponse(bool Approved, string? Comments); + +/// Looks up expense details and creates an approval request. +internal sealed class CreateApprovalRequest() : Executor("RetrieveRequest") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // In a real scenario, this would look up expense details from a database + return new ValueTask(new ApprovalRequest(message, 1500.00m, "Jerry")); + } +} + +/// Prepares the approval request for finance review after manager approval. +internal sealed class PrepareFinanceReview() : Executor("PrepareFinanceReview") +{ + public override ValueTask HandleAsync( + ApprovalResponse message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (!message.Approved) + { + throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense."); + } + + // In a real scenario, this would retrieve the original expense details + return new ValueTask(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry")); + } +} + +/// Processes the expense reimbursement based on the parallel approval responses. +internal sealed class ExpenseReimburse() : Executor("Reimburse") +{ + public override async ValueTask HandleAsync( + ApprovalResponse[] message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // Check that all parallel approvals passed + ApprovalResponse? denied = Array.Find(message, r => !r.Approved); + if (denied is not null) + { + return $"Expense reimbursement denied. Comments: {denied.Comments}"; + } + + // Simulate payment processing + await Task.Delay(1000, cancellationToken); + return $"Expense reimbursed at {DateTime.UtcNow:O}"; + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs new file mode 100644 index 0000000000..1aa1972e62 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/Program.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions. +// +// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ +// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ +// │ ├─►│ExpenseReimburse │ +// │ ┌────────────────────┐ │ └─────────────────┘ +// └►│ComplianceApproval │──┘ +// │ (RequestPort) │ +// └────────────────────┘ +// +// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance. +// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in. +// The framework auto-generates three HTTP endpoints for each workflow: +// POST /api/workflows/{name}/run - Start the workflow +// GET /api/workflows/{name}/status/{id} - Check status and pending approvals +// POST /api/workflows/{name}/respond/{id} - Send approval response to resume + +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using WorkflowHITLFunctions; + +// Define executors and RequestPorts for the three HITL pause points +CreateApprovalRequest createRequest = new(); +RequestPort managerApproval = RequestPort.Create("ManagerApproval"); +PrepareFinanceReview prepareFinanceReview = new(); +RequestPort budgetApproval = RequestPort.Create("BudgetApproval"); +RequestPort complianceApproval = RequestPort.Create("ComplianceApproval"); +ExpenseReimburse reimburse = new(); + +// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse +Workflow expenseApproval = new WorkflowBuilder(createRequest) + .WithName("ExpenseReimbursement") + .WithDescription("Expense reimbursement with manager and parallel finance approvals") + .AddEdge(createRequest, managerApproval) + .AddEdge(managerApproval, prepareFinanceReview) + .AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval]) + .AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse) + .Build(); + +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(expenseApproval, exposeStatusEndpoint: true)) + .Build(); +app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md new file mode 100644 index 0000000000..27322b7b6a --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/README.md @@ -0,0 +1,266 @@ +# Human-in-the-Loop (HITL) Workflow — Azure Functions + +This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at three `RequestPort` nodes — one sequential manager approval, then two parallel finance approvals (budget and compliance) via fan-out/fan-in. Approval responses are sent via HTTP endpoints. + +## Key Concepts Demonstrated + +- Using multiple `RequestPort` nodes for sequential and parallel human-in-the-loop interactions in a durable workflow +- Fan-out/fan-in pattern for parallel approval steps +- Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses +- Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync` +- Viewing inputs the workflow is waiting for via the status endpoint + +## Workflow + +This sample implements the following workflow: + +``` +┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ + └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ + │ ├─►│ExpenseReimburse │ + │ ┌────────────────────┐ │ └─────────────────┘ + └►│ComplianceApproval │──┘ + │ (RequestPort) │ + └────────────────────┘ +``` + +## HTTP Endpoints + +The framework auto-generates these endpoints for workflows with `RequestPort` nodes: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/workflows/ExpenseReimbursement/run` | Start the workflow | +| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and inputs the workflow is waiting for | +| POST | `/api/workflows/ExpenseReimbursement/respond/{runId}` | Send approval response to resume | + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on how to configure the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints. + +You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below: + +### Step 1: Start the Workflow + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/run \ + -H "Content-Type: text/plain" -d "EXP-2025-001" +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/run ` + -ContentType text/plain ` + -Body "EXP-2025-001" +``` + +The response will confirm the workflow orchestration has started: + +```text +Workflow orchestration started for ExpenseReimbursement. Orchestration runId: abc123def456 +``` + +> [!TIP] +> You can provide a custom run ID by appending a `runId` query parameter: +> +> Bash (Linux/macOS/WSL): +> +> ```bash +> curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \ +> -H "Content-Type: text/plain" -d "EXP-2025-001" +> ``` +> +> PowerShell: +> +> ```powershell +> Invoke-RestMethod -Method Post ` +> -Uri "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" ` +> -ContentType text/plain ` +> -Body "EXP-2025-001" +> ``` +> +> If not provided, a unique run ID is auto-generated. + +### Step 2: Check Workflow Status + +The workflow pauses at the `ManagerApproval` RequestPort. Query the status endpoint to see what input it is waiting for: + +Bash (Linux/macOS/WSL): + +```bash +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +```json +{ + "runId": "{runId}", + "status": "Running", + "waitingForInput": [ + { "eventName": "ManagerApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } + ] +} +``` + +> [!TIP] +> You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event. + +### Step 3: Send Manager Approval Response + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ + -H "Content-Type: application/json" \ + -d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` + -ContentType application/json ` + -Body '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}' +``` + +```json +{ + "message": "Response sent to workflow.", + "runId": "{runId}", + "eventName": "ManagerApproval", + "validated": true +} +``` + +### Step 4: Check Workflow Status Again + +The workflow now pauses at both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel: + +Bash (Linux/macOS/WSL): + +```bash +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +```json +{ + "runId": "{runId}", + "status": "Running", + "waitingForInput": [ + { "eventName": "BudgetApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }, + { "eventName": "ComplianceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } } + ] +} +``` + +### Step 5a: Send Budget Approval Response + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ + -H "Content-Type: application/json" \ + -d '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` + -ContentType application/json ` + -Body '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}' +``` + +```json +{ + "message": "Response sent to workflow.", + "runId": "{runId}", + "eventName": "BudgetApproval", + "validated": true +} +``` + +### Step 5b: Send Compliance Approval Response + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \ + -H "Content-Type: application/json" \ + -d '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} ` + -ContentType application/json ` + -Body '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}' +``` + +```json +{ + "message": "Response sent to workflow.", + "runId": "{runId}", + "eventName": "ComplianceApproval", + "validated": true +} +``` + +### Step 6: Check Final Status + +After all approvals, the workflow completes and the expense is reimbursed: + +Bash (Linux/macOS/WSL): + +```bash +curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId} +``` + +```json +{ + "runId": "{runId}", + "status": "Completed", + "waitingForInput": null +} +``` + +### Viewing Workflows in the DTS Dashboard + +After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the orchestration and inspect its execution history. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. + +1. Open the dashboard and look for the orchestration instance matching the `runId` returned in Step 1 (e.g., `abc123def456` or your custom ID like `expense-001`). +2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals. +3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http new file mode 100644 index 0000000000..5e2993ac1c --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/demo.http @@ -0,0 +1,53 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Step 1: Start the expense reimbursement workflow +POST {{authority}}/api/workflows/ExpenseReimbursement/run +Content-Type: text/plain + +EXP-2025-001 + +### Step 1 (alternative): Start the workflow with a custom run ID +POST {{authority}}/api/workflows/ExpenseReimbursement/run?runId=expense-001 +Content-Type: text/plain + +EXP-2025-001 + +### Step 2: Check workflow status (replace {runId} with actual run ID from Step 1) +GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} + +### Step 3: Send manager approval (replace {runId} with actual run ID from Step 1) +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}} + +### Step 3 (alternative): Deny the expense at manager level +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}} + +### Step 4: Check workflow status after manager approval (now waiting for parallel finance approvals) +GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} + +### Step 5a: Send budget approval (replace {runId} with actual run ID from Step 1) +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}} + +### Step 5b: Send compliance approval (replace {runId} with actual run ID from Step 1) +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}} + +### Step 5b (alternative): Deny the expense at compliance level +POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId} +Content-Type: application/json + +{"eventName": "ComplianceApproval", "response": {"Approved": false, "Comments": "Compliance requirements not met."}} + +### Step 6: Check final workflow status after all approvals +GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json new file mode 100644 index 0000000000..5f6d7d3340 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT_NAME": "" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj new file mode 100644 index 0000000000..8a5308a6f5 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj @@ -0,0 +1,29 @@ + + + net10.0 + Exe + enable + enable + SequentialWorkflow + SequentialWorkflow + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs new file mode 100644 index 0000000000..474cb8bcaa --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/OrderCancelExecutors.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace SequentialWorkflow; + +/// +/// Represents a request to cancel an order. +/// +/// The ID of the order to cancel. +/// The reason for cancellation. +internal sealed record OrderCancelRequest(string OrderId, string Reason); + +/// +/// Looks up an order by its ID and return an Order object. +/// +internal sealed class OrderLookup() : Executor("OrderLookup") +{ + public override async ValueTask HandleAsync( + OrderCancelRequest message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message.OrderId}'"); + Console.WriteLine($"│ [Activity] OrderLookup: Cancellation reason: '{message.Reason}'"); + Console.ResetColor(); + + // Simulate database lookup with delay + await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken); + + Order order = new( + Id: message.OrderId, + OrderDate: DateTime.UtcNow.AddDays(-1), + IsCancelled: false, + CancelReason: message.Reason, + Customer: new Customer(Name: "Jerry", Email: "jerry@example.com")); + + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message.OrderId}' for customer '{order.Customer.Name}'"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return order; + } +} + +/// +/// Cancels an order. +/// +internal sealed class OrderCancel() : Executor("OrderCancel") +{ + public override async ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // Log that this activity is executing (not replaying) + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'"); + Console.ResetColor(); + + // Simulate a slow cancellation process (e.g., calling external payment system) + for (int i = 1; i <= 3; i++) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine("│ [Activity] OrderCancel: Processing..."); + Console.ResetColor(); + } + + Order cancelledOrder = message with { IsCancelled = true }; + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return cancelledOrder; + } +} + +/// +/// Sends a cancellation confirmation email to the customer. +/// +internal sealed class SendEmail() : Executor("SendEmail") +{ + public override ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'..."); + Console.ResetColor(); + + string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}."; + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(result); + } +} + +internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer); + +internal sealed record Customer(string Name, string Email); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs new file mode 100644 index 0000000000..03e4ed5928 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/Program.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SequentialWorkflow; + +// Get DTS connection string from environment variable +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Define executors for the workflow +OrderLookup orderLookup = new(); +OrderCancel orderCancel = new(); +SendEmail sendEmail = new(); + +// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail +Workflow cancelOrder = new WorkflowBuilder(orderLookup) + .WithName("CancelOrder") + .WithDescription("Cancel an order and notify the customer") + .AddEdge(orderLookup, orderCancel) + .AddEdge(orderCancel, sendEmail) + .Build(); + +IHost host = Host.CreateDefaultBuilder(args) +.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) +.ConfigureServices(services => +{ + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(cancelOrder), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); +}) +.Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Durable Workflow Sample"); +Console.WriteLine("Workflow: OrderLookup -> OrderCancel -> SendEmail"); +Console.WriteLine(); +Console.WriteLine("Enter an order ID (or 'exit'):"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + OrderCancelRequest request = new(OrderId: input, Reason: "Customer requested cancellation"); + await StartNewWorkflowAsync(request, cancelOrder, workflowClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); + +// Start a new workflow using IWorkflowClient with typed input +static async Task StartNewWorkflowAsync(OrderCancelRequest request, Workflow workflow, IWorkflowClient client) +{ + Console.WriteLine($"Starting workflow for order '{request.OrderId}' (Reason: {request.Reason})..."); + + // RunAsync returns IWorkflowRun, cast to IAwaitableWorkflowRun for completion waiting + IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, request); + Console.WriteLine($"Run ID: {run.RunId}"); + + try + { + Console.WriteLine("Waiting for workflow to complete..."); + string? result = await run.WaitForCompletionAsync(); + Console.WriteLine($"Workflow completed. {result}"); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Failed: {ex.Message}"); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md new file mode 100644 index 0000000000..ac5a3e43f5 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/README.md @@ -0,0 +1,83 @@ +# Sequential Workflow Sample + +This sample demonstrates how to run a sequential workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow automatically resumes without re-executing completed activities. + +## Key Concepts Demonstrated + +- Building a sequential workflow with the `WorkflowBuilder` API +- Using `ConfigureDurableWorkflows` to register workflows with dependency injection +- Running workflows with `IWorkflowClient` +- **Durability**: Automatic resume of interrupted workflows +- **Activity caching**: Completed activities are not re-executed on replay + +## Overview + +The sample implements an order cancellation workflow with three executors: + +``` +OrderLookup --> OrderCancel --> SendEmail +``` + +| Executor | Description | +|----------|-------------| +| OrderLookup | Looks up an order by ID | +| OrderCancel | Marks the order as cancelled | +| SendEmail | Sends a cancellation confirmation email | + +## Durability Demonstration + +The key feature of Durable Task Framework is **durability**: + +- **Activity results are persisted**: When an activity completes, its result is saved +- **Orchestrations replay**: On restart, the orchestration replays from the beginning +- **Completed activities skip execution**: The framework uses cached results +- **Automatic resume**: The worker automatically picks up pending work on startup + +### Try It Yourself + +> **Tip:** To give yourself more time to stop the application during `OrderCancel`, consider increasing the loop iteration count or `Task.Delay` duration in the `OrderCancel` executor in `OrderCancelExecutors.cs`. + +1. Start the application and enter an order ID (e.g., `12345`) +2. Wait for `OrderLookup` to complete, then stop the app (Ctrl+C) during `OrderCancel` +3. Restart the application +4. Observe: + - `OrderLookup` is **NOT** re-executed (result was cached) + - `OrderCancel` **restarts** (it didn't complete before the interruption) + - `SendEmail` runs after `OrderCancel` completes + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +```bash +cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow +dotnet run --framework net10.0 +``` + +### Sample Output + +```text +Durable Workflow Sample +Workflow: OrderLookup -> OrderCancel -> SendEmail + +Enter an order ID (or 'exit'): +> 12345 +Starting workflow for order: 12345 +Run ID: abc123... + +[OrderLookup] Looking up order '12345'... +[OrderLookup] Found order for customer 'Jerry' + +[OrderCancel] Cancelling order '12345'... +[OrderCancel] Order cancelled successfully + +[SendEmail] Sending email to 'jerry@example.com'... +[SendEmail] Email sent successfully + +Workflow completed! + +> exit +``` + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj new file mode 100644 index 0000000000..a05822a286 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj @@ -0,0 +1,30 @@ + + + net10.0 + Exe + enable + enable + WorkflowConcurrency + WorkflowConcurrency + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs new file mode 100644 index 0000000000..40674126f6 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/ExpertExecutors.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowConcurrency; + +/// +/// Parses and validates the incoming question before sending to AI agents. +/// +internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents..."); + + string formattedQuestion = message.Trim(); + if (!formattedQuestion.EndsWith('?')) + { + formattedQuestion += "?"; + } + + Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\""); + Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL..."); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(formattedQuestion); + } +} + +/// +/// Aggregates responses from all AI agents into a comprehensive answer. +/// This is the Fan-in point where parallel results are collected. +/// +internal sealed class AggregatorExecutor() : Executor("Aggregator") +{ + public override ValueTask HandleAsync( + string[] message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses"); + Console.WriteLine("│ [Aggregator] Combining into comprehensive answer..."); + Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" + + " AI EXPERT PANEL RESPONSES\n" + + "═══════════════════════════════════════════════════════════════\n\n"; + + for (int i = 0; i < message.Length; i++) + { + string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST"; + aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; + } + + aggregatedResult += "═══════════════════════════════════════════════════════════════\n" + + $"Summary: Received perspectives from {message.Length} AI experts.\n" + + "═══════════════════════════════════════════════════════════════"; + + return ValueTask.FromResult(aggregatedResult); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs new file mode 100644 index 0000000000..ae68a56562 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/Program.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow. +// The workflow uses 4 executors: 2 class-based executors and 2 AI agents. +// +// WORKFLOW PATTERN: +// +// ParseQuestion (class-based) +// | +// +----------+----------+ +// | | +// Physicist Chemist +// (AI Agent) (AI Agent) +// | | +// +----------+----------+ +// | +// Aggregator (class-based) + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenAI.Chat; +using WorkflowConcurrency; + +// Configuration +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set."); +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); + +// Create Azure OpenAI client +AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); +ChatClient chatClient = openAiClient.GetChatClient(deploymentName); + +// Define the 4 executors for the workflow +ParseQuestionExecutor parseQuestion = new(); +AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist"); +AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist"); +AggregatorExecutor aggregator = new(); + +// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator +Workflow workflow = new WorkflowBuilder(parseQuestion) + .WithName("ExpertReview") + .AddFanOutEdge(parseQuestion, [physicist, chemist]) + .AddFanInBarrierEdge([physicist, chemist], aggregator) + .Build(); + +// Configure and start the host +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableOptions( + options => options.Workflows.AddWorkflow(workflow), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Fan-out/Fan-in Workflow Sample"); +Console.WriteLine("ParseQuestion -> [Physicist, Chemist] -> Aggregator"); +Console.WriteLine(); +Console.WriteLine("Enter a science question (or 'exit' to quit):"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + IWorkflowRun run = await workflowClient.RunAsync(workflow, input); + Console.WriteLine($"Run ID: {run.RunId}"); + + if (run is IAwaitableWorkflowRun awaitableRun) + { + string? result = await awaitableRun.WaitForCompletionAsync(); + + Console.WriteLine("Workflow completed!"); + Console.WriteLine(result); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md new file mode 100644 index 0000000000..4887a77ccc --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/README.md @@ -0,0 +1,100 @@ +# Concurrent Workflow Sample (Fan-Out/Fan-In) + +This sample demonstrates the **fan-out/fan-in** pattern in a durable workflow, combining class-based executors with AI agents running in parallel. + +## Key Concepts Demonstrated + +- **Fan-out/Fan-in pattern**: Parallel execution with result aggregation +- **Mixed executor types**: Class-based executors and AI agents in the same workflow +- **AI agents as executors**: Using `ChatClient.AsAIAgent()` to create workflow-compatible agents +- **Workflow registration**: Auto-registration of agents used within workflows +- **Standalone agents**: Registering agents outside of workflows + +## Overview + +The sample implements an expert review workflow with four executors: + +``` + ParseQuestion + | + +----------+----------+ + | | + Physicist Chemist + (AI Agent) (AI Agent) + | | + +----------+----------+ + | + Aggregator +``` + +| Executor | Type | Description | +|----------|------|-------------| +| ParseQuestion | Class-based | Parses the user's question for expert review | +| Physicist | AI Agent | Provides physics perspective (runs in parallel) | +| Chemist | AI Agent | Provides chemistry perspective (runs in parallel) | +| Aggregator | Class-based | Combines expert responses into a final answer | + +## Fan-Out/Fan-In Pattern + +The workflow demonstrates the fan-out/fan-in pattern: + +1. **Fan-out**: `ParseQuestion` sends the question to both `Physicist` and `Chemist` simultaneously +2. **Parallel execution**: Both AI agents process the question concurrently +3. **Fan-in**: `Aggregator` waits for both agents to complete, then combines their responses + +This pattern is useful for: +- Gathering multiple perspectives on a problem +- Parallel processing of independent tasks +- Reducing overall execution time through concurrency + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on configuring the environment. + +### Required Environment Variables + +```bash +# Durable Task Scheduler (optional, defaults to localhost) +DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" + +# Azure OpenAI (required) +AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +AZURE_OPENAI_DEPLOYMENT="gpt-4o" +AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials +``` + +## Running the Sample + +```bash +cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow +dotnet run --framework net10.0 +``` + +### Sample Output + +```text ++-----------------------------------------------------------------------+ +| Fan-out/Fan-in Workflow Sample (4 Executors) | +| | +| ParseQuestion -> [Physicist, Chemist] -> Aggregator | +| (class-based) (AI agents, parallel) (class-based) | ++-----------------------------------------------------------------------+ + +Enter a science question (or 'exit' to quit): + +Question: Why is the sky blue? +Instance: abc123... + +[ParseQuestion] Parsing question for expert review... +[Physicist] Analyzing from physics perspective... +[Chemist] Analyzing from chemistry perspective... +[Aggregator] Combining expert responses... + +Workflow completed! + +Physics perspective: The sky appears blue due to Rayleigh scattering... +Chemistry perspective: The molecular composition of our atmosphere... +Combined answer: ... + +Question: exit +``` diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj new file mode 100644 index 0000000000..b488b10425 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj @@ -0,0 +1,29 @@ + + + net10.0 + Exe + enable + enable + ConditionalEdges + ConditionalEdges + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs new file mode 100644 index 0000000000..d22ac39e68 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/NotifyFraud.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace ConditionalEdges; + +internal sealed class Order +{ + public Order(string id, decimal amount) + { + this.Id = id; + this.Amount = amount; + } + public string Id { get; } + public decimal Amount { get; } + public Customer? Customer { get; set; } + public string? PaymentReferenceNumber { get; set; } +} + +public sealed record Customer(int Id, string Name, bool IsBlocked); + +internal sealed class OrderIdParser() : Executor("OrderIdParser") +{ + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return GetOrder(message); + } + + private static Order GetOrder(string id) + { + // Simulate fetching order details + return new Order(id, 100.0m); + } +} + +internal sealed class OrderEnrich() : Executor("EnrichOrder") +{ + public override async ValueTask HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + message.Customer = GetCustomerForOrder(message.Id); + return message; + } + + private static Customer GetCustomerForOrder(string orderId) + { + if (orderId.Contains('B')) + { + return new Customer(101, "George", true); + } + + return new Customer(201, "Jerry", false); + } +} + +internal sealed class PaymentProcessor() : Executor("PaymentProcessor") +{ + public override async ValueTask HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + // Call payment gateway. + message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4); + return message; + } +} + +internal sealed class NotifyFraud() : Executor("NotifyFraud") +{ + public override async ValueTask HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + // Notify fraud team. + return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}."; + } +} + +internal static class OrderRouteConditions +{ + /// + /// Returns a condition that evaluates to true when the customer is blocked. + /// + internal static Func WhenBlocked() => order => order?.Customer?.IsBlocked == true; + + /// + /// Returns a condition that evaluates to true when the customer is not blocked. + /// + internal static Func WhenNotBlocked() => order => order?.Customer?.IsBlocked == false; +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs new file mode 100644 index 0000000000..b7f9ff9944 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/Program.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates conditional edges in a workflow. +// Orders are routed to different executors based on customer status: +// - Blocked customers → NotifyFraud +// - Valid customers → PaymentProcessor + +using ConditionalEdges; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Create executor instances +OrderIdParser orderParser = new(); +OrderEnrich orderEnrich = new(); +PaymentProcessor paymentProcessor = new(); +NotifyFraud notifyFraud = new(); + +// Build workflow with conditional edges +// The condition functions evaluate the Order output from OrderEnrich +WorkflowBuilder builder = new(orderParser); +builder + .AddEdge(orderParser, orderEnrich) + .AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked()) + .AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked()); + +Workflow auditOrder = builder.WithName("AuditOrder").Build(); + +IHost host = Host.CreateDefaultBuilder(args) +.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) +.ConfigureServices(services => +{ + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(auditOrder), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); +}) +.Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Enter an order ID (or 'exit'):"); +Console.WriteLine("Tip: Order IDs containing 'B' are flagged as blocked customers.\n"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + await StartNewWorkflowAsync(input, auditOrder, workflowClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); + +// Start a new workflow and wait for completion +static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client) +{ + Console.WriteLine($"Starting workflow for order '{orderId}'..."); + + // Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync + IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, orderId); + Console.WriteLine($"Run ID: {run.RunId}"); + + try + { + Console.WriteLine("Waiting for workflow to complete..."); + string? result = await run.WaitForCompletionAsync(); + Console.WriteLine($"Workflow completed. {result}"); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Failed: {ex.Message}"); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md new file mode 100644 index 0000000000..fb8c26bf80 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/README.md @@ -0,0 +1,92 @@ +# Conditional Edges Workflow Sample + +This sample demonstrates how to build a workflow with **conditional edges** that route execution to different paths based on runtime conditions. The workflow evaluates conditions on the output of an executor to determine which downstream executor to run. + +## Key Concepts Demonstrated + +- Building workflows with **conditional edges** using `AddEdge` with a `condition` parameter +- Defining reusable condition functions for routing logic +- Branching workflow execution based on data-driven decisions +- Using `ConfigureDurableWorkflows` to register workflows with dependency injection + +## Overview + +The sample implements an order audit workflow that routes orders differently based on whether the customer is blocked (flagged for fraud): + +``` +OrderIdParser --> OrderEnrich --[IsBlocked]--> NotifyFraud + | + +--[NotBlocked]--> PaymentProcessor +``` + +| Executor | Description | +|----------|-------------| +| OrderIdParser | Parses the order ID and retrieves order details | +| OrderEnrich | Enriches the order with customer information | +| PaymentProcessor | Processes payment for valid orders | +| NotifyFraud | Notifies the fraud team for blocked customers | + +## How Conditional Edges Work + +Conditional edges allow you to specify a condition function that determines whether the edge should be traversed: + +```csharp +builder + .AddEdge(orderParser, orderEnrich) + .AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked()) + .AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked()); +``` + +The condition functions receive the output of the source executor and return a boolean: + +```csharp +internal static class OrderRouteConditions +{ + // Routes to NotifyFraud when customer is blocked + internal static Func WhenBlocked() => + order => order?.Customer?.IsBlocked == true; + + // Routes to PaymentProcessor when customer is not blocked + internal static Func WhenNotBlocked() => + order => order?.Customer?.IsBlocked == false; +} +``` + +### Routing Logic + +In this sample, the routing is based on the order ID: +- Order IDs containing the letter **'B'** are associated with blocked customers → routed to `NotifyFraud` +- All other order IDs are associated with valid customers → routed to `PaymentProcessor` + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +```bash +cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges +dotnet run --framework net10.0 +``` + +### Sample Output + +**Valid order (routes to PaymentProcessor):** +```text +Enter an order ID (or 'exit'): +> 12345 +Starting workflow for order '12345'... +Run ID: abc123... +Waiting for workflow to complete... +Workflow completed. {"Id":"12345","Amount":100.0,"Customer":{"Id":201,"Name":"Jerry","IsBlocked":false},"PaymentReferenceNumber":"a1b2"} +``` + +**Blocked order (routes to NotifyFraud):** +```text +Enter an order ID (or 'exit'): +> 12345B +Starting workflow for order '12345B'... +Run ID: def456... +Waiting for workflow to complete... +Workflow completed. Order 12345B flagged as fraudulent for customer George. +``` diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj new file mode 100644 index 0000000000..a05822a286 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj @@ -0,0 +1,30 @@ + + + net10.0 + Exe + enable + enable + WorkflowConcurrency + WorkflowConcurrency + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs new file mode 100644 index 0000000000..e9a6712393 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/ParseQuestionExecutor.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowConcurrency; + +/// +/// Parses and validates the incoming question before sending to AI agents. +/// +internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents..."); + + string formattedQuestion = message.Trim(); + if (!formattedQuestion.EndsWith('?')) + { + formattedQuestion += "?"; + } + + Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\""); + Console.WriteLine("│ [ParseQuestion] → Sending to experts..."); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult(formattedQuestion); + } +} + +/// +/// Aggregates responses from multiple AI agents into a unified response. +/// This executor collects all expert opinions and synthesizes them. +/// +internal sealed class ResponseAggregatorExecutor() : Executor("ResponseAggregator") +{ + public override ValueTask HandleAsync( + string[] message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses"); + Console.WriteLine("│ [Aggregator] Combining into comprehensive answer..."); + Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" + + " AI EXPERT PANEL RESPONSES\n" + + "═══════════════════════════════════════════════════════════════\n\n"; + + for (int i = 0; i < message.Length; i++) + { + string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST"; + aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; + } + + aggregatedResult += "═══════════════════════════════════════════════════════════════\n" + + $"Summary: Received perspectives from {message.Length} AI experts.\n" + + "═══════════════════════════════════════════════════════════════"; + + return ValueTask.FromResult(aggregatedResult); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs new file mode 100644 index 0000000000..5dfec4f277 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates the THREE ways to configure durable agents and workflows: +// +// 1. ConfigureDurableAgents() - For standalone agents only +// 2. ConfigureDurableWorkflows() - For workflows only +// 3. ConfigureDurableOptions() - For both agents AND workflows +// +// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE. + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenAI.Chat; +using WorkflowConcurrency; + +// Configuration +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set."); +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); + +// Create AI agents +AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); +ChatClient chatClient = openAiClient.GetChatClient(deploymentName); + +AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist"); +AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist"); +AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist"); + +// Create workflows +ParseQuestionExecutor questionParser = new(); +ResponseAggregatorExecutor responseAggregator = new(); + +Workflow physicsWorkflow = new WorkflowBuilder(questionParser) + .WithName("PhysicsExpertReview") + .AddEdge(questionParser, physicist) + .Build(); + +Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser) +.WithName("ExpertTeamReview") +.AddFanOutEdge(questionParser, [biologist, physicist]) +.AddFanInBarrierEdge([biologist, physicist], responseAggregator) +.Build(); + +Workflow chemistryWorkflow = new WorkflowBuilder(questionParser) + .WithName("ChemistryExpertReview") + .AddEdge(questionParser, chemist) + .Build(); + +// Configure services - demonstrating all 3 methods (each can be called multiple times) +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + // METHOD 1: ConfigureDurableAgents - for standalone agents only + services.ConfigureDurableAgents( + options => options.AddAIAgent(biologist), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + + // METHOD 2: ConfigureDurableWorkflows - for workflows only + services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow)); + + // METHOD 3: ConfigureDurableOptions - for both agents AND workflows + services.ConfigureDurableOptions(options => + { + options.Agents.AddAIAgent(chemist); + options.Workflows.AddWorkflow(expertTeamWorkflow); + }); + + // Second call to ConfigureDurableOptions (additive - adds to existing config) + services.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow)); + }) + .Build(); + +await host.StartAsync(); +IServiceProvider services = host.Services; +IWorkflowClient workflowClient = services.GetRequiredService(); + +// DEMO 1: Direct agent conversation (standalone agents) +Console.WriteLine("\n═══ DEMO 1: Direct Agent Conversation ═══\n"); + +AIAgent biologistProxy = services.GetRequiredKeyedService("Biologist"); +AgentSession session = await biologistProxy.CreateSessionAsync(); +AgentResponse response = await biologistProxy.RunAsync("What is photosynthesis?", session); +Console.WriteLine($"🧬 Biologist: {response.Text}\n"); + +AIAgent chemistProxy = services.GetRequiredKeyedService("Chemist"); +session = await chemistProxy.CreateSessionAsync(); +response = await chemistProxy.RunAsync("What is a chemical bond?", session); +Console.WriteLine($"🧪 Chemist: {response.Text}\n"); + +// DEMO 2: Single-agent workflow +Console.WriteLine("═══ DEMO 2: Single-Agent Workflow ═══\n"); +await RunWorkflowAsync(workflowClient, physicsWorkflow, "What is the relationship between energy and mass?"); + +// DEMO 3: Multi-agent workflow +Console.WriteLine("═══ DEMO 3: Multi-Agent Workflow ═══\n"); +await RunWorkflowAsync(workflowClient, expertTeamWorkflow, "How does radiation affect living cells?"); + +// DEMO 4: Workflow from second ConfigureDurableOptions call +Console.WriteLine("═══ DEMO 4: Workflow (added via 2nd ConfigureDurableOptions) ═══\n"); +await RunWorkflowAsync(workflowClient, chemistryWorkflow, "What happens during combustion?"); + +Console.WriteLine("\n✅ All demos completed!"); +await host.StopAsync(); + +// Helper method +static async Task RunWorkflowAsync(IWorkflowClient client, Workflow workflow, string question) +{ + Console.WriteLine($"📋 {workflow.Name}: \"{question}\""); + IWorkflowRun run = await client.RunAsync(workflow, question); + if (run is IAwaitableWorkflowRun awaitable) + { + string? result = await awaitable.WaitForCompletionAsync(); + Console.WriteLine($"✅ {result}\n"); + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj new file mode 100644 index 0000000000..09e20ef622 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj @@ -0,0 +1,28 @@ + + + net10.0 + Exe + enable + enable + WorkflowEvents + WorkflowEvents + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs new file mode 100644 index 0000000000..47880f0fff --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Executors.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowEvents; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Custom event types - callers observe these via WatchStreamAsync +// ═══════════════════════════════════════════════════════════════════════════════ + +internal sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent(orderId) +{ + public string OrderId { get; } = orderId; +} + +internal sealed class OrderFoundEvent(string customerName) : WorkflowEvent(customerName) +{ + public string CustomerName { get; } = customerName; +} + +internal sealed class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status) +{ + public int PercentComplete { get; } = percentComplete; + public string Status { get; } = status; +} + +internal sealed class OrderCancelledEvent() : WorkflowEvent("Order cancelled"); + +internal sealed class EmailSentEvent(string email) : WorkflowEvent(email) +{ + public string Email { get; } = email; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Domain models +// ═══════════════════════════════════════════════════════════════════════════════ + +internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer); + +internal sealed record Customer(string Name, string Email); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Executors - emit events via AddEventAsync and YieldOutputAsync +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// Looks up an order by ID, emitting progress events. +/// +internal sealed class OrderLookup() : Executor("OrderLookup") +{ + public override async ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await context.AddEventAsync(new OrderLookupStartedEvent(message), cancellationToken); + + // Simulate database lookup + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + + Order order = new( + Id: message, + OrderDate: DateTime.UtcNow.AddDays(-1), + IsCancelled: false, + CancelReason: "Customer requested cancellation", + Customer: new Customer(Name: "Jerry", Email: "jerry@example.com")); + + await context.AddEventAsync(new OrderFoundEvent(order.Customer.Name), cancellationToken); + + // YieldOutputAsync emits a WorkflowOutputEvent observable via streaming + await context.YieldOutputAsync(order, cancellationToken); + + return order; + } +} + +/// +/// Cancels an order, emitting progress events during the multi-step process. +/// +internal sealed class OrderCancel() : Executor("OrderCancel") +{ + public override async ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await context.AddEventAsync(new CancellationProgressEvent(0, "Starting cancellation"), cancellationToken); + + // Simulate a multi-step cancellation process + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + await context.AddEventAsync(new CancellationProgressEvent(33, "Contacting payment provider"), cancellationToken); + + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + await context.AddEventAsync(new CancellationProgressEvent(66, "Processing refund"), cancellationToken); + + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + + Order cancelledOrder = message with { IsCancelled = true }; + await context.AddEventAsync(new CancellationProgressEvent(100, "Complete"), cancellationToken); + await context.AddEventAsync(new OrderCancelledEvent(), cancellationToken); + + await context.YieldOutputAsync(cancelledOrder, cancellationToken); + + return cancelledOrder; + } +} + +/// +/// Sends a cancellation confirmation email, emitting an event on completion. +/// +internal sealed class SendEmail() : Executor("SendEmail") +{ + public override async ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // Simulate sending email + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + + string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}."; + + await context.AddEventAsync(new EmailSentEvent(message.Customer.Email), cancellationToken); + + await context.YieldOutputAsync(result, cancellationToken); + + return result; + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs new file mode 100644 index 0000000000..3ddec1db37 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/Program.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ═══════════════════════════════════════════════════════════════════════════════ +// SAMPLE: Workflow Events and Streaming +// ═══════════════════════════════════════════════════════════════════════════════ +// +// This sample demonstrates how to use IWorkflowContext event methods in executors +// and stream events from the caller side: +// +// 1. AddEventAsync - Emit custom events that callers can observe in real-time +// 2. StreamAsync - Start a workflow and obtain a streaming handle +// 3. WatchStreamAsync - Observe events as they occur (custom, framework, and terminal) +// +// The sample uses IWorkflowClient.StreamAsync to start a workflow and +// WatchStreamAsync to observe events as they occur in real-time. +// +// Workflow: OrderLookup -> OrderCancel -> SendEmail +// ═══════════════════════════════════════════════════════════════════════════════ + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WorkflowEvents; + +// Get DTS connection string from environment variable +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Define executors and build workflow +OrderLookup orderLookup = new(); +OrderCancel orderCancel = new(); +SendEmail sendEmail = new(); + +Workflow cancelOrder = new WorkflowBuilder(orderLookup) + .WithName("CancelOrder") + .WithDescription("Cancel an order and notify the customer") + .AddEdge(orderLookup, orderCancel) + .AddEdge(orderCancel, sendEmail) + .Build(); + +// Configure host with durable workflow support +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(cancelOrder), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Workflow Events Demo - Enter order ID (or 'exit'):"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + await RunWorkflowWithStreamingAsync(input, cancelOrder, workflowClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); + +// Runs a workflow and streams events as they occur +static async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client) +{ + // StreamAsync starts the workflow and returns a streaming handle for observing events + IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId); + Console.WriteLine($"Started run: {run.RunId}"); + + // WatchStreamAsync yields events as they're emitted by executors + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + Console.WriteLine($" New event received at {DateTime.Now:HH:mm:ss.ffff} ({evt.GetType().Name})"); + + switch (evt) + { + // Custom domain events (emitted via AddEventAsync) + case OrderLookupStartedEvent e: + WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan); + break; + case OrderFoundEvent e: + WriteColored($" [Lookup] Found: {e.CustomerName}", ConsoleColor.Cyan); + break; + case CancellationProgressEvent e: + WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow); + break; + case OrderCancelledEvent: + WriteColored(" [Cancel] Done", ConsoleColor.Yellow); + break; + case EmailSentEvent e: + WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta); + break; + + case WorkflowOutputEvent e: + WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray); + break; + + // Workflow completion + case DurableWorkflowCompletedEvent e: + WriteColored($" Completed: {e.Result}", ConsoleColor.Green); + break; + case DurableWorkflowFailedEvent e: + WriteColored($" Failed: {e.ErrorMessage}", ConsoleColor.Red); + break; + } + } +} + +static void WriteColored(string message, ConsoleColor color) +{ + Console.ForegroundColor = color; + Console.WriteLine(message); + Console.ResetColor(); +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md new file mode 100644 index 0000000000..b519ec8d5c --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/README.md @@ -0,0 +1,127 @@ +# Workflow Events Sample + +This sample demonstrates how to use workflow events and streaming in durable workflows. + +## What it demonstrates + +1. **Custom Events** (`AddEventAsync`) — Executors emit domain-specific events during execution +2. **Event Streaming** (`StreamAsync` / `WatchStreamAsync`) — Callers observe events in real-time as the workflow progresses +3. **Framework Events** — Automatic `ExecutorInvokedEvent`, `ExecutorCompletedEvent`, and `WorkflowOutputEvent` events emitted by the framework + +## Emitting Custom Events + +Executors can emit custom domain events during execution using the `IWorkflowContext` instance passed to `HandleAsync`. These events are streamed to callers in real-time via `WatchStreamAsync`. + +### Defining a custom event + +Create a class that inherits from `WorkflowEvent`. Pass any data payload to the base constructor: + +```csharp +public class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status) +{ + public int PercentComplete { get; } = percentComplete; + public string Status { get; } = status; +} +``` + +### Emitting the event from an executor + +Call `AddEventAsync` on the `IWorkflowContext` inside your executor's `HandleAsync` method: + +```csharp +public override async ValueTask HandleAsync( + Order message, + IWorkflowContext context, + CancellationToken cancellationToken = default) +{ + await context.AddEventAsync(new CancellationProgressEvent(33, "Processing refund"), cancellationToken); + // ... rest of the executor logic +} +``` + +### Observing events from the caller + +Use `StreamAsync` to start the workflow and `WatchStreamAsync` to observe events. Pattern match on your custom event types: + +```csharp +IStreamingWorkflowRun run = await workflowClient.StreamAsync(workflow, input); + +await foreach (WorkflowEvent evt in run.WatchStreamAsync()) +{ + switch (evt) + { + case CancellationProgressEvent e: + Console.WriteLine($"{e.PercentComplete}% - {e.Status}"); + break; + } +} +``` + +## Workflow Structure + +``` +OrderLookup → OrderCancel → SendEmail +``` + +Each executor emits custom events during execution: +- `OrderLookup` emits `OrderLookupStartedEvent` and `OrderFoundEvent` +- `OrderCancel` emits `CancellationProgressEvent` (with percentage) and `OrderCancelledEvent` +- `SendEmail` emits `EmailSentEvent` + +## Prerequisites + +- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure +- Set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable (defaults to local emulator) + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. + +## Running the sample + +```bash +dotnet run +``` + +Enter an order ID at the prompt to start a workflow and watch events stream in real-time: + +```text +> order-42 +Started run: b6ba4d19... + New event received at 13:27:41.4956 (ExecutorInvokedEvent) + New event received at 13:27:41.5019 (OrderLookupStartedEvent) + [Lookup] Looking up order order-42 + New event received at 13:27:41.5025 (OrderFoundEvent) + [Lookup] Found: Jerry + New event received at 13:27:41.5026 (ExecutorCompletedEvent) + New event received at 13:27:41.5026 (WorkflowOutputEvent) + [Output] OrderLookup + New event received at 13:27:43.0772 (ExecutorInvokedEvent) + New event received at 13:27:43.0773 (CancellationProgressEvent) + [Cancel] 0% - Starting cancellation + New event received at 13:27:43.0775 (CancellationProgressEvent) + [Cancel] 33% - Contacting payment provider + New event received at 13:27:43.0776 (CancellationProgressEvent) + [Cancel] 66% - Processing refund + New event received at 13:27:43.0777 (CancellationProgressEvent) + [Cancel] 100% - Complete + New event received at 13:27:43.0779 (OrderCancelledEvent) + [Cancel] Done + New event received at 13:27:43.0780 (ExecutorCompletedEvent) + New event received at 13:27:43.0780 (WorkflowOutputEvent) + [Output] OrderCancel + New event received at 13:27:43.6610 (ExecutorInvokedEvent) + New event received at 13:27:43.6611 (EmailSentEvent) + [Email] Sent to jerry@example.com + New event received at 13:27:43.6613 (ExecutorCompletedEvent) + New event received at 13:27:43.6613 (WorkflowOutputEvent) + [Output] SendEmail + New event received at 13:27:43.6619 (DurableWorkflowCompletedEvent) + Completed: Cancellation email sent for order order-42 to jerry@example.com. +``` + +### Viewing Workflows in the DTS Dashboard + +After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the workflow execution and events. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj new file mode 100644 index 0000000000..c7efbb7d1b --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj @@ -0,0 +1,29 @@ + + + net10.0 + Exe + enable + enable + WorkflowSharedState + WorkflowSharedState + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs new file mode 100644 index 0000000000..57d2964c0c --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Executors.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowSharedState; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Domain models +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// The primary order data passed through the pipeline via return values. +/// +internal sealed record OrderDetails(string OrderId, string CustomerName, decimal Amount, DateTime OrderDate); + +/// +/// Cross-cutting audit trail accumulated in shared state across executors. +/// Each executor appends its step name and timestamp. This data does not flow +/// through return values — it lives only in shared state. +/// +internal sealed record AuditEntry(string Step, string Timestamp, string Detail); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Executors +// ═══════════════════════════════════════════════════════════════════════════════ + +/// +/// Validates the order and writes the initial audit entry and tax rate to shared state. +/// The order details are returned as the executor output (normal message flow), +/// while the audit trail and tax rate are stored in shared state (side-channel). +/// If the order ID starts with "INVALID", the executor halts the workflow early +/// using . +/// +[YieldsOutput(typeof(string))] +internal sealed class ValidateOrder() : Executor("ValidateOrder") +{ + public override async ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); + + // Halt the workflow early if the order ID is invalid. + // No downstream executors will run after this. + if (message.StartsWith("INVALID", StringComparison.OrdinalIgnoreCase)) + { + await context.YieldOutputAsync($"Order '{message}' failed validation. Halting workflow.", cancellationToken); + await context.RequestHaltAsync(); + return new OrderDetails(message, "Unknown", 0, DateTime.UtcNow); + } + + OrderDetails details = new(message, "Jerry", 249.99m, DateTime.UtcNow); + + // Store the tax rate in shared state — downstream ProcessPayment reads it + // without needing it in the message chain. + await context.QueueStateUpdateAsync("taxRate", 0.085m, cancellationToken: cancellationToken); + Console.WriteLine(" Wrote to shared state: taxRate = 8.5%"); + + // Start the audit trail in shared state + AuditEntry audit = new("ValidateOrder", DateTime.UtcNow.ToString("o"), $"Validated order {message}"); + await context.QueueStateUpdateAsync("auditValidate", audit, cancellationToken: cancellationToken); + Console.WriteLine(" Wrote to shared state: auditValidate"); + + await context.YieldOutputAsync($"Order '{message}' validated. Customer: {details.CustomerName}, Amount: {details.Amount:C}", cancellationToken); + + return details; + } +} + +/// +/// Enriches the order with shipping information. +/// Reads the audit trail from shared state and appends its own entry. +/// Uses ReadOrInitStateAsync to lazily initialize a shipping tier. +/// Demonstrates custom scopes by writing shipping details under the "shipping" scope. +/// +[YieldsOutput(typeof(string))] +internal sealed class EnrichOrder() : Executor("EnrichOrder") +{ + public override async ValueTask HandleAsync( + OrderDetails message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken); + + // Use ReadOrInitStateAsync — only initializes if no value exists yet + string shippingTier = await context.ReadOrInitStateAsync( + "shippingTier", + () => "Express", + cancellationToken: cancellationToken); + Console.WriteLine($" Read from shared state: shippingTier = {shippingTier}"); + + // Write carrier under a custom "shipping" scope. + // This keeps the key separate from keys written without a scope, + // so "carrier" here won't collide with a "carrier" key written elsewhere. + await context.QueueStateUpdateAsync("carrier", "Contoso Express", scopeName: "shipping", cancellationToken: cancellationToken); + Console.WriteLine(" Wrote to shared state: carrier = Contoso Express (scope: shipping)"); + + // Verify we can read the audit entry from the previous step + AuditEntry? previousAudit = await context.ReadStateAsync("auditValidate", cancellationToken: cancellationToken); + string auditStatus = previousAudit is not null ? $"(previous step: {previousAudit.Step})" : "(no prior audit)"; + Console.WriteLine($" Read from shared state: auditValidate {auditStatus}"); + + // Append our own audit entry + AuditEntry audit = new("EnrichOrder", DateTime.UtcNow.ToString("o"), $"Enriched with {shippingTier} shipping {auditStatus}"); + await context.QueueStateUpdateAsync("auditEnrich", audit, cancellationToken: cancellationToken); + Console.WriteLine(" Wrote to shared state: auditEnrich"); + + await context.YieldOutputAsync($"Order enriched. Shipping: {shippingTier} {auditStatus}", cancellationToken); + + return message; + } +} + +/// +/// Processes payment using the tax rate from shared state (written by ValidateOrder). +/// The tax rate is side-channel data — it doesn't flow through return values. +/// +internal sealed class ProcessPayment() : Executor("ProcessPayment") +{ + public override async ValueTask HandleAsync( + OrderDetails message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken); + + // Read tax rate written by ValidateOrder — not available in the message chain + decimal taxRate = await context.ReadOrInitStateAsync("taxRate", () => 0.0m, cancellationToken: cancellationToken); + Console.WriteLine($" Read from shared state: taxRate = {taxRate:P1}"); + + decimal tax = message.Amount * taxRate; + decimal total = message.Amount + tax; + string paymentRef = $"PAY-{Guid.NewGuid():N}"[..16]; + + // Append audit entry + AuditEntry audit = new("ProcessPayment", DateTime.UtcNow.ToString("o"), $"Charged {total:C} (tax: {tax:C})"); + await context.QueueStateUpdateAsync("auditPayment", audit, cancellationToken: cancellationToken); + Console.WriteLine(" Wrote to shared state: auditPayment"); + + await context.YieldOutputAsync($"Payment processed. Total: {total:C} (tax: {tax:C}). Ref: {paymentRef}", cancellationToken); + + return paymentRef; + } +} + +/// +/// Generates the final invoice by reading the full audit trail from shared state. +/// Demonstrates reading multiple state entries written by different executors +/// and clearing a scope with . +/// +internal sealed class GenerateInvoice() : Executor("GenerateInvoice") +{ + public override async ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + // Read the full audit trail from shared state — each step wrote its own entry + AuditEntry? validateAudit = await context.ReadStateAsync("auditValidate", cancellationToken: cancellationToken); + AuditEntry? enrichAudit = await context.ReadStateAsync("auditEnrich", cancellationToken: cancellationToken); + AuditEntry? paymentAudit = await context.ReadStateAsync("auditPayment", cancellationToken: cancellationToken); + int auditCount = new[] { validateAudit, enrichAudit, paymentAudit }.Count(a => a is not null); + Console.WriteLine($" Read from shared state: {auditCount} audit entries"); + + // Read carrier from the "shipping" scope (written by EnrichOrder) + string? carrier = await context.ReadStateAsync("carrier", scopeName: "shipping", cancellationToken: cancellationToken); + Console.WriteLine($" Read from shared state: carrier = {carrier} (scope: shipping)"); + + // Clear the "shipping" scope — no longer needed after invoice generation. + await context.QueueClearScopeAsync("shipping", cancellationToken); + Console.WriteLine(" Cleared shared state scope: shipping"); + + string auditSummary = string.Join(" → ", new[] + { + validateAudit?.Step, enrichAudit?.Step, paymentAudit?.Step + }.Where(s => s is not null)); + + return $"Invoice complete. Payment: {message}. Audit trail: [{auditSummary}]"; + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs new file mode 100644 index 0000000000..2513cc2dad --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/Program.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ═══════════════════════════════════════════════════════════════════════════════ +// SAMPLE: Shared State During Workflow Execution +// ═══════════════════════════════════════════════════════════════════════════════ +// +// This sample demonstrates how executors in a durable workflow can share state +// via IWorkflowContext. State is persisted across supersteps and survives +// process restarts because the orchestration passes it to each activity. +// +// Key concepts: +// 1. QueueStateUpdateAsync - Write a value to shared state +// 2. ReadStateAsync - Read a value written by a previous executor +// 3. ReadOrInitStateAsync - Read or lazily initialize a state value +// 4. QueueClearScopeAsync - Clear all entries under a scope +// 5. RequestHaltAsync - Stop the workflow early (e.g., validation failure) +// +// Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice +// +// Return values carry primary business data through the pipeline (OrderDetails, +// payment ref). Shared state carries side-channel data that doesn't belong in +// the message chain: a tax rate (set by ValidateOrder, read by ProcessPayment) +// and an audit trail (each executor appends its own entry). +// ═══════════════════════════════════════════════════════════════════════════════ + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WorkflowSharedState; + +// Get DTS connection string from environment variable +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Define executors +ValidateOrder validateOrder = new(); +EnrichOrder enrichOrder = new(); +ProcessPayment processPayment = new(); +GenerateInvoice generateInvoice = new(); + +// Build the workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice +Workflow orderPipeline = new WorkflowBuilder(validateOrder) + .WithName("OrderPipeline") + .WithDescription("Order processing pipeline with shared state across executors") + .AddEdge(validateOrder, enrichOrder) + .AddEdge(enrichOrder, processPayment) + .AddEdge(processPayment, generateInvoice) + .Build(); + +// Configure host with durable workflow support +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(orderPipeline), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Shared State Workflow Demo"); +Console.WriteLine("Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice"); +Console.WriteLine(); +Console.WriteLine("Enter an order ID (or 'exit'):"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + // Start the workflow and stream events to see shared state in action + IStreamingWorkflowRun run = await workflowClient.StreamAsync(orderPipeline, input); + Console.WriteLine($"Started run: {run.RunId}"); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + case WorkflowOutputEvent e: + Console.WriteLine($" [Output] {e.ExecutorId}: {e.Data}"); + break; + + case DurableWorkflowCompletedEvent e: + Console.WriteLine($" Completed: {e.Result}"); + break; + + case DurableWorkflowFailedEvent e: + Console.WriteLine($" Failed: {e.ErrorMessage}"); + break; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md new file mode 100644 index 0000000000..31ff55ce84 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/README.md @@ -0,0 +1,71 @@ +# Shared State Workflow Sample + +This sample demonstrates how executors in a durable workflow can share state via `IWorkflowContext`. State written by one executor is accessible to all downstream executors, persisted across supersteps, and survives process restarts. + +## Key Concepts Demonstrated + +- Writing state with `QueueStateUpdateAsync` — executors store data for downstream executors +- Reading state with `ReadStateAsync` — executors access data written by earlier executors +- Lazy initialization with `ReadOrInitStateAsync` — initialize state only if not already present +- Custom scopes with `scopeName` — partition state into isolated namespaces (e.g., `"shipping"`) +- Clearing scopes with `QueueClearScopeAsync` — remove all entries under a scope when no longer needed +- Early termination with `RequestHaltAsync` — halt the workflow when validation fails +- State persistence across supersteps — the orchestration passes shared state to each executor +- Event streaming with `IStreamingWorkflowRun` — observe executor progress in real time + +## Workflow + +**OrderPipeline**: `ValidateOrder` → `EnrichOrder` → `ProcessPayment` → `GenerateInvoice` + +Return values carry primary business data through the pipeline (`OrderDetails` → `OrderDetails` → payment ref → invoice string). Shared state carries side-channel data that doesn't belong in the message chain: + +| Executor | Returns (message flow) | Reads from State | Writes to State | +|----------|----------------------|-----------------|-----------------| +| **ValidateOrder** | `OrderDetails` | — | `taxRate`, `auditValidate` | +| **EnrichOrder** | `OrderDetails` (pass-through) | `auditValidate` | `shippingTier`, `auditEnrich`, `carrier` (scope: shipping) | +| **ProcessPayment** | payment ref string | `taxRate` | `auditPayment` | +| **GenerateInvoice** | invoice string | `auditValidate`, `auditEnrich`, `auditPayment`, `carrier` (scope: shipping) | clears `shipping` scope | + +> [!NOTE] +> `EnrichOrder` writes `carrier` under the `"shipping"` scope using `scopeName: "shipping"`. This keeps the key separate from keys written without a scope, so `"carrier"` in the `"shipping"` scope won't collide with a `"carrier"` key written elsewhere. + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. + +## Running the Sample + +```bash +dotnet run +``` + +Enter an order ID when prompted. The workflow will process the order through all four executors, streaming events as they occur: + +```text +> ORD-001 +Started run: abc123 + Wrote to shared state: taxRate = 8.5% + Wrote to shared state: auditValidate + [Output] ValidateOrder: Order 'ORD-001' validated. Customer: Jerry, Amount: $249.99 + Read from shared state: shippingTier = Express + Wrote to shared state: carrier = Contoso Express (scope: shipping) + Read from shared state: auditValidate (previous step: ValidateOrder) + Wrote to shared state: auditEnrich + [Output] EnrichOrder: Order enriched. Shipping: Express (previous step: ValidateOrder) + Read from shared state: taxRate = 8.5% + Wrote to shared state: auditPayment + [Output] ProcessPayment: Payment processed. Total: $271.24 (tax: $21.25). Ref: PAY-abc123def456 + Read from shared state: 3 audit entries + Read from shared state: carrier = Contoso Express (scope: shipping) + Cleared shared state scope: shipping + [Output] GenerateInvoice: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment] + Completed: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment] +``` + +### Viewing Workflows in the DTS Dashboard + +After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration status, executor inputs/outputs, and events. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. + +To inspect shared state in the dashboard, click on an executor to view its input and output. The input contains a snapshot of the shared state the executor ran with, and the output includes any state updates it made (as `stateUpdates` with scoped keys). diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj new file mode 100644 index 0000000000..d8d36ead01 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj @@ -0,0 +1,28 @@ + + + net10.0 + Exe + enable + enable + SubWorkflows + SubWorkflows + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs new file mode 100644 index 0000000000..121db7af67 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Executors.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace SubWorkflows; + +/// +/// Event emitted when the fraud check risk score is calculated. +/// +internal sealed class FraudRiskAssessedEvent(int riskScore) : WorkflowEvent($"Risk score: {riskScore}/100") +{ + public int RiskScore => riskScore; +} + +/// +/// Represents an order being processed through the workflow. +/// +internal sealed class OrderInfo +{ + public required string OrderId { get; set; } + + public decimal Amount { get; set; } + + public string? PaymentTransactionId { get; set; } + + public string? TrackingNumber { get; set; } + + public string? Carrier { get; set; } +} + +// Main workflow executors + +/// +/// Entry point executor that receives the order ID and creates an OrderInfo object. +/// +internal sealed class OrderReceived() : Executor("OrderReceived") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"[OrderReceived] Processing order '{message}'"); + Console.ResetColor(); + + OrderInfo order = new() + { + OrderId = message, + Amount = 99.99m // Simulated order amount + }; + + return ValueTask.FromResult(order); + } +} + +/// +/// Final executor that outputs the completed order summary. +/// +internal sealed class OrderCompleted() : Executor("OrderCompleted") +{ + public override ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐"); + Console.WriteLine($"│ [OrderCompleted] Order '{message.OrderId}' successfully processed!"); + Console.WriteLine($"│ Payment: {message.PaymentTransactionId}"); + Console.WriteLine($"│ Shipping: {message.Carrier} - {message.TrackingNumber}"); + Console.WriteLine("└─────────────────────────────────────────────────────────────────┘"); + Console.ResetColor(); + + return ValueTask.FromResult($"Order {message.OrderId} completed. Tracking: {message.TrackingNumber}"); + } +} + +// Payment sub-workflow executors + +/// +/// Validates payment information for an order. +/// +internal sealed class ValidatePayment() : Executor("ValidatePayment") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Payment/ValidatePayment] Validating payment for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Payment/ValidatePayment] Payment validated for ${message.Amount}"); + Console.ResetColor(); + + return message; + } +} + +/// +/// Charges the payment for an order. +/// +internal sealed class ChargePayment() : Executor("ChargePayment") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Payment/ChargePayment] Charging ${message.Amount} for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + message.PaymentTransactionId = $"TXN-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Payment/ChargePayment] ✓ Payment processed: {message.PaymentTransactionId}"); + Console.ResetColor(); + + return message; + } +} + +// FraudCheck sub-sub-workflow executors (nested inside Payment) + +/// +/// Analyzes transaction patterns for potential fraud. +/// +internal sealed class AnalyzePatterns() : Executor("AnalyzePatterns") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + // Store analysis results in shared state for the next executor in this sub-workflow + int patternsFound = new Random().Next(0, 5); + await context.QueueStateUpdateAsync("patternsFound", patternsFound, cancellationToken: cancellationToken); + + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete ({patternsFound} suspicious patterns)"); + Console.ResetColor(); + + return message; + } +} + +/// +/// Calculates a risk score for the transaction. +/// +internal sealed class CalculateRiskScore() : Executor("CalculateRiskScore") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + // Read the pattern count from shared state (written by AnalyzePatterns) + int patternsFound = await context.ReadStateAsync("patternsFound", cancellationToken: cancellationToken); + int riskScore = Math.Min(patternsFound * 20 + new Random().Next(1, 20), 100); + + // Emit a workflow event from within a nested sub-workflow + await context.AddEventAsync(new FraudRiskAssessedEvent(riskScore), cancellationToken); + + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: {riskScore}/100 (based on {patternsFound} patterns)"); + Console.ResetColor(); + + return message; + } +} + +// Shipping sub-workflow executors + +/// +/// Selects a shipping carrier for an order. +/// +/// +/// This executor uses (void return) combined with +/// to forward the order to the next +/// connected executor (CreateShipment). This demonstrates explicit typed message passing +/// as an alternative to returning a value from the handler. +/// +internal sealed class SelectCarrier() : Executor("SelectCarrier") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine($" [Shipping/SelectCarrier] Selecting carrier for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + message.Carrier = message.Amount > 50 ? "Express" : "Standard"; + + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}"); + Console.ResetColor(); + + // Use SendMessageAsync to forward the updated order to connected executors. + // With a void-return executor, this is the mechanism for passing data downstream. + await context.SendMessageAsync(message, cancellationToken: cancellationToken); + } +} + +/// +/// Creates shipment and generates tracking number. +/// +internal sealed class CreateShipment() : Executor("CreateShipment") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine($" [Shipping/CreateShipment] Creating shipment for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + message.TrackingNumber = $"TRACK-{Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()}"; + + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine($" [Shipping/CreateShipment] ✓ Shipment created: {message.TrackingNumber}"); + Console.ResetColor(); + + return message; + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs new file mode 100644 index 0000000000..d542f4aba5 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/Program.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates nested sub-workflows. A sub-workflow can act as an executor +// within another workflow, including multi-level nesting (sub-workflow within sub-workflow). + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SubWorkflows; + +// Get DTS connection string from environment variable +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Build the FraudCheck sub-workflow (this will be nested inside the Payment sub-workflow) +AnalyzePatterns analyzePatterns = new(); +CalculateRiskScore calculateRiskScore = new(); + +Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns) + .WithName("SubFraudCheck") + .WithDescription("Analyzes transaction patterns and calculates risk score") + .AddEdge(analyzePatterns, calculateRiskScore) + .Build(); + +// Build the Payment sub-workflow: ValidatePayment -> FraudCheck (sub-workflow) -> ChargePayment +ValidatePayment validatePayment = new(); +ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck"); +ChargePayment chargePayment = new(); + +Workflow paymentWorkflow = new WorkflowBuilder(validatePayment) + .WithName("SubPaymentProcessing") + .WithDescription("Validates and processes payment for an order") + .AddEdge(validatePayment, fraudCheckExecutor) + .AddEdge(fraudCheckExecutor, chargePayment) + .Build(); + +// Build the Shipping sub-workflow: SelectCarrier -> CreateShipment +SelectCarrier selectCarrier = new(); +CreateShipment createShipment = new(); + +Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier) + .WithName("SubShippingArrangement") + .WithDescription("Selects carrier and creates shipment") + .AddEdge(selectCarrier, createShipment) + .Build(); + +// Build the main workflow using sub-workflows as executors +// OrderReceived -> Payment (sub-workflow) -> Shipping (sub-workflow) -> OrderCompleted +OrderReceived orderReceived = new(); +OrderCompleted orderCompleted = new(); +ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment"); +ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping"); + +Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived) + .WithName("OrderProcessing") + .WithDescription("Processes an order through payment and shipping") + .AddEdge(orderReceived, paymentExecutor) + .AddEdge(paymentExecutor, shippingExecutor) + .AddEdge(shippingExecutor, orderCompleted) + .Build(); + +// Configure and start the host +// Register only the main workflow - sub-workflows are discovered automatically! +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(orderProcessingWorkflow), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Durable Sub-Workflows Sample"); +Console.WriteLine("Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted"); +Console.WriteLine(" Payment contains nested FraudCheck sub-workflow (Level 2 nesting)"); +Console.WriteLine(); +Console.WriteLine("Enter an order ID (or 'exit'):"); + +while (true) +{ + Console.Write("> "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + try + { + await StartNewWorkflowAsync(input, orderProcessingWorkflow, workflowClient); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.WriteLine(); +} + +await host.StopAsync(); + +// Start a new workflow using streaming to observe events (including from sub-workflows) +static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client) +{ + Console.WriteLine($"\nStarting order processing for '{orderId}'..."); + + IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId); + Console.WriteLine($"Run ID: {run.RunId}"); + Console.WriteLine(); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + // Custom event emitted from the FraudCheck sub-sub-workflow + case FraudRiskAssessedEvent e: + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Event from sub-workflow] {e.GetType().Name}: Risk score {e.RiskScore}/100"); + Console.ResetColor(); + break; + + case DurableWorkflowCompletedEvent e: + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✓ Order completed: {e.Result}"); + Console.ResetColor(); + break; + + case DurableWorkflowFailedEvent e: + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Failed: {e.ErrorMessage}"); + Console.ResetColor(); + break; + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md new file mode 100644 index 0000000000..83968eee0e --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/README.md @@ -0,0 +1,105 @@ +# Sub-Workflows Sample (Nested Workflows) + +This sample demonstrates how to compose complex workflows from simpler, reusable sub-workflows. Sub-workflows are built using `WorkflowBuilder` and embedded as executors via `BindAsExecutor()`. Unlike the in-process workflow runner, the durable workflow backend persists execution state across process restarts — each sub-workflow runs as a separate orchestration instance on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard. + +## Key Concepts Demonstrated + +- **Sub-workflows**: Using `Workflow.BindAsExecutor()` to embed a workflow as an executor in another workflow +- **Multi-level nesting**: Sub-workflows within sub-workflows (Level 2 nesting) +- **Automatic discovery**: Registering only the main workflow; sub-workflows are discovered automatically +- **Failure isolation**: Each sub-workflow runs as a separate orchestration instance on the DTS backend +- **Hierarchical visualization**: Parent-child orchestration hierarchy visible in the DTS dashboard +- **Event propagation**: Custom workflow events (`FraudRiskAssessedEvent`) bubble up from nested sub-workflows to the streaming client +- **Message passing**: Using `Executor` (void return) with `SendMessageAsync` to forward typed messages to connected executors (`SelectCarrier`) +- **Shared state within sub-workflows**: Using `QueueStateUpdateAsync`/`ReadStateAsync` to share data between executors within a sub-workflow (`AnalyzePatterns` → `CalculateRiskScore`) + +## Overview + +The sample implements an order processing workflow composed of two sub-workflows, one of which contains its own nested sub-workflow: + +``` +OrderProcessing (main workflow) +├── OrderReceived +├── Payment (sub-workflow) +│ ├── ValidatePayment +│ ├── FraudCheck (sub-sub-workflow) ← Level 2 nesting! +│ │ ├── AnalyzePatterns +│ │ └── CalculateRiskScore +│ └── ChargePayment +├── Shipping (sub-workflow) +│ ├── SelectCarrier ← Uses SendMessageAsync (void-return executor) +│ └── CreateShipment +└── OrderCompleted +``` + +| Executor | Sub-Workflow | Description | +|----------|-------------|-------------| +| OrderReceived | Main | Receives order ID and creates order info | +| ValidatePayment | Payment | Validates payment information | +| AnalyzePatterns | FraudCheck (nested in Payment) | Analyzes transaction patterns, stores results in shared state | +| CalculateRiskScore | FraudCheck (nested in Payment) | Reads shared state, calculates risk score, emits `FraudRiskAssessedEvent` | +| ChargePayment | Payment | Charges payment amount | +| SelectCarrier | Shipping | Selects carrier using `SendMessageAsync` (void-return executor) | +| CreateShipment | Shipping | Creates shipment with tracking | +| OrderCompleted | Main | Outputs completed order summary | + +## How Sub-Workflows Work + +For an introduction to sub-workflows and the `BindAsExecutor()` API, see the [Sub-Workflows foundational sample](../../../../03-workflows/_StartHere/05_SubWorkflows). + +This durable sample extends the same pattern — the key difference is that each sub-workflow runs as a **separate orchestration instance** on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard. + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +```bash +cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows +dotnet run --framework net10.0 +``` + +### Sample Output + +```text +Durable Sub-Workflows Sample +Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted + Payment contains nested FraudCheck sub-workflow (Level 2 nesting) + +Enter an order ID (or 'exit'): +> ORD-001 +Starting order processing for 'ORD-001'... +Run ID: abc123... + +[OrderReceived] Processing order 'ORD-001' + [Payment/ValidatePayment] Validating payment for order 'ORD-001'... + [Payment/ValidatePayment] Payment validated for $99.99 + [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order 'ORD-001'... + [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete (2 suspicious patterns) + [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order 'ORD-001'... + [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: 53/100 (based on 2 patterns) + [Event from sub-workflow] FraudRiskAssessedEvent: Risk score 53/100 + [Payment/ChargePayment] Charging $99.99 for order 'ORD-001'... + [Payment/ChargePayment] ✓ Payment processed: TXN-A1B2C3D4 + [Shipping/SelectCarrier] Selecting carrier for order 'ORD-001'... + [Shipping/SelectCarrier] ✓ Selected carrier: Express + [Shipping/CreateShipment] Creating shipment for order 'ORD-001'... + [Shipping/CreateShipment] ✓ Shipment created: TRACK-I9J0K1L2M3 +┌─────────────────────────────────────────────────────────────────┐ +│ [OrderCompleted] Order 'ORD-001' successfully processed! +│ Payment: TXN-A1B2C3D4 +│ Shipping: Express - TRACK-I9J0K1L2M3 +└─────────────────────────────────────────────────────────────────┘ +✓ Order completed: Order ORD-001 completed. Tracking: TRACK-I9J0K1L2M3 + +> exit +``` + +### Viewing Workflows in the DTS Dashboard + +After running the workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration hierarchy, including sub-orchestrations. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. + +Because each sub-workflow runs as a separate orchestration instance, the dashboard shows a parent-child hierarchy: the top-level `OrderProcessing` orchestration with `Payment` and `Shipping` as child orchestrations, and `FraudCheck` nested under `Payment`. You can click into each orchestration to inspect its executor inputs/outputs, events, and execution timeline independently. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj new file mode 100644 index 0000000000..a9103b6e48 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj @@ -0,0 +1,28 @@ + + + net10.0 + Exe + enable + enable + WorkflowHITL + WorkflowHITL + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs new file mode 100644 index 0000000000..2006b1cd19 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Executors.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowHITL; + +/// +/// Represents an expense approval request. +/// +/// The unique identifier of the expense. +/// The amount of the expense. +/// The name of the employee submitting the expense. +public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName); + +/// +/// Represents the response to an approval request. +/// +/// Whether the expense was approved. +/// Optional comments from the approver. +public record ApprovalResponse(bool Approved, string? Comments); + +/// +/// Retrieves expense details and creates an approval request. +/// +internal sealed class CreateApprovalRequest() : Executor("RetrieveRequest") +{ + /// + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // In a real scenario, this would look up expense details from a database + return new ValueTask(new ApprovalRequest(message, 1500.00m, "Jerry")); + } +} + +/// +/// Prepares the approval request for finance review after manager approval. +/// +internal sealed class PrepareFinanceReview() : Executor("PrepareFinanceReview") +{ + /// + public override ValueTask HandleAsync( + ApprovalResponse message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + if (!message.Approved) + { + throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense."); + } + + // In a real scenario, this would retrieve the original expense details + return new ValueTask(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry")); + } +} + +/// +/// Processes the expense reimbursement based on the parallel approval responses from budget and compliance. +/// +internal sealed class ExpenseReimburse() : Executor("Reimburse") +{ + /// + public override async ValueTask HandleAsync( + ApprovalResponse[] message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + // Check that all parallel approvals passed + ApprovalResponse? denied = Array.Find(message, r => !r.Approved); + if (denied is not null) + { + return $"Expense reimbursement denied. Comments: {denied.Comments}"; + } + + // Simulate payment processing + await Task.Delay(1000, cancellationToken); + return $"Expense reimbursed at {DateTime.UtcNow:O}"; + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs new file mode 100644 index 0000000000..bc8fe00341 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/Program.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks. +// +// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ +// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ +// │ ├─►│ExpenseReimburse │ +// │ ┌────────────────────┐ │ └─────────────────┘ +// └►│ComplianceApproval │──┘ +// │ (RequestPort) │ +// └────────────────────┘ +// +// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance. +// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WorkflowHITL; + +string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"; + +// Define executors and RequestPorts for the three HITL pause points +CreateApprovalRequest createRequest = new(); +RequestPort managerApproval = RequestPort.Create("ManagerApproval"); +PrepareFinanceReview prepareFinanceReview = new(); +RequestPort budgetApproval = RequestPort.Create("BudgetApproval"); +RequestPort complianceApproval = RequestPort.Create("ComplianceApproval"); +ExpenseReimburse reimburse = new(); + +// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse +Workflow expenseApproval = new WorkflowBuilder(createRequest) + .WithName("ExpenseReimbursement") + .WithDescription("Expense reimbursement with manager and parallel finance approvals") + .AddEdge(createRequest, managerApproval) + .AddEdge(managerApproval, prepareFinanceReview) + .AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval]) + .AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse) + .Build(); + +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + options => options.AddWorkflow(expenseApproval), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +// Start the workflow with streaming to observe events including HITL pauses +string expenseId = "EXP-2025-001"; +Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}"); +IStreamingWorkflowRun run = await workflowClient.StreamAsync(expenseApproval, expenseId); +Console.WriteLine($"Workflow started with instance ID: {run.RunId}\n"); + +// Watch for workflow events — handle HITL requests as they arrive +await foreach (WorkflowEvent evt in run.WatchStreamAsync()) +{ + switch (evt) + { + case DurableWorkflowWaitingForInputEvent requestEvent: + Console.WriteLine($"Workflow paused at RequestPort: {requestEvent.RequestPort.Id}"); + Console.WriteLine($" Input: {requestEvent.Input}"); + + // In a real scenario, this would involve human interaction (UI, email, Teams, etc.) + ApprovalRequest? request = requestEvent.GetInputAs(); + Console.WriteLine($" Approval for: {request?.EmployeeName}, Amount: {request?.Amount:C}"); + + ApprovalResponse approvalResponse = new(Approved: true, Comments: "Approved by manager."); + await run.SendResponseAsync(requestEvent, approvalResponse); + Console.WriteLine($" Response sent: Approved={approvalResponse.Approved}\n"); + break; + + case DurableWorkflowCompletedEvent completedEvent: + Console.WriteLine($"Workflow completed: {completedEvent.Result}"); + break; + + case DurableWorkflowFailedEvent failedEvent: + Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}"); + break; + } +} + +await host.StopAsync(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md new file mode 100644 index 0000000000..f659077371 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/README.md @@ -0,0 +1,106 @@ +# Workflow Human-in-the-Loop (HITL) Sample + +This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at a manager approval point, then fans out to two parallel finance approval points — budget and compliance — before resuming. + +## Key Concepts Demonstrated + +- Using `RequestPort` to define external input points in a workflow +- Sequential and parallel HITL pause points in a single workflow using fan-out/fan-in +- Streaming workflow events with `IStreamingWorkflowRun` +- Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses +- Using `SendResponseAsync` to provide responses and resume the workflow +- **Durability**: The workflow survives process restarts while waiting for human input + +## Workflow + +This sample implements the following workflow: + +``` +┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐ +│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐ +└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │ + └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐ + │ ├─►│ExpenseReimburse │ + │ ┌────────────────────┐ │ └─────────────────┘ + └►│ComplianceApproval │──┘ + │ (RequestPort) │ + └────────────────────┘ +``` + +| Step | Description | +|------|-------------| +| CreateApprovalRequest | Retrieves expense details and creates an approval request | +| ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval | +| PrepareFinanceReview | Prepares the request for finance review after manager approval | +| BudgetApproval (RequestPort) | **PAUSES** the workflow and waits for budget approval (parallel) | +| ComplianceApproval (RequestPort) | **PAUSES** the workflow and waits for compliance approval (parallel) | +| ExpenseReimburse | Processes the reimbursement after all approvals pass | + +## How It Works + +A `RequestPort` defines a typed external input point in the workflow: + +```csharp +RequestPort managerApproval = + RequestPort.Create("ManagerApproval"); +``` + +Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPort`, a `DurableWorkflowWaitingForInputEvent` is emitted. Call `SendResponseAsync` to provide the response and resume the workflow: + +```csharp +await foreach (WorkflowEvent evt in run.WatchStreamAsync()) +{ + switch (evt) + { + case DurableWorkflowWaitingForInputEvent requestEvent: + ApprovalRequest? request = requestEvent.GetInputAs(); + await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved.")); + break; + } +} +``` + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler. + +## Running the Sample + +```bash +cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL +dotnet run --framework net10.0 +``` + +### Sample Output + +```text +Starting expense reimbursement workflow for expense: EXP-2025-001 +Workflow started with instance ID: abc123... + +Workflow paused at RequestPort: ManagerApproval + Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} + Approval for: Jerry, Amount: $1,500.00 + Response sent: Approved=True + +Workflow paused at RequestPort: BudgetApproval + Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} + Approval for: Jerry, Amount: $1,500.00 + Response sent: Approved=True + +Workflow paused at RequestPort: ComplianceApproval + Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"} + Approval for: Jerry, Amount: $1,500.00 + Response sent: Approved=True + +Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z +``` + +### Viewing Workflows in the DTS Dashboard + +After running the sample, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration and inspect its execution history. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. + +1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`). +2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals. +3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props b/dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props new file mode 100644 index 0000000000..3723bee3cc --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/Directory.Build.props @@ -0,0 +1,5 @@ + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/README.md new file mode 100644 index 0000000000..2b7103de50 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/README.md @@ -0,0 +1,50 @@ +# Durable Workflow Samples + +This directory contains samples demonstrating how to build durable workflows using the Microsoft Agent Framework. + +## Environment Setup + +### Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later +- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure + +### Running the Durable Task Scheduler Emulator + +To run the emulator locally using Docker: + +```bash +docker run -d -p 8080:8080 --name durabletask-emulator mcr.microsoft.com/durabletask/emulator:latest +``` + +Set the connection string environment variable to point to the local emulator: + +```bash +# Linux/macOS +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="AccountEndpoint=http://localhost:8080" + +# Windows (PowerShell) +$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhost:8080" +``` + +## Samples + +### Console Apps + +| Sample | Description | +|--------|-------------| +| [01_SequentialWorkflow](ConsoleApps/01_SequentialWorkflow/) | Basic sequential workflow with ordered executor steps | +| [02_ConcurrentWorkflow](ConsoleApps/02_ConcurrentWorkflow/) | Fan-out/fan-in concurrent workflow execution | +| [03_ConditionalEdges](ConsoleApps/03_ConditionalEdges/) | Workflows with conditional routing between executors | +| [05_WorkflowEvents](ConsoleApps/05_WorkflowEvents/) | Publishing and subscribing to workflow events | +| [06_WorkflowSharedState](ConsoleApps/06_WorkflowSharedState/) | Sharing state across workflow executors | +| [07_SubWorkflows](ConsoleApps/07_SubWorkflows/) | Nested sub-workflow composition | +| [08_WorkflowHITL](ConsoleApps/08_WorkflowHITL/) | Human-in-the-loop workflow with approval gates | + +### Azure Functions + +| Sample | Description | +|--------|-------------| +| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions | +| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions | +| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions | diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 74a52faf6f..b77e1d5804 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,7 +2,36 @@ ## [Unreleased] -### Changed +- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) + +## v1.0.0-preview.260219.1 + +- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806)) +- Marked all `RunAsync` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803)) +- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973)) + +## v1.0.0-preview.260212.1 + +- [BREAKING] Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879)) + +## v1.0.0-preview.260209.1 + +- [BREAKING] Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699)) + +## v1.0.0-preview.260205.1 + +- [BREAKING] Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650)) +- [BREAKING] Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681)) + +## v1.0.0-preview.260127.1 + +- [BREAKING] Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430)) + +## v1.0.0-preview.260108.1 + +- [BREAKING] Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067)) + +## v1.0.0-preview.251219.1 - Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670)) @@ -12,15 +41,6 @@ - Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679)) - Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843)) -- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067)); -- Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430)) -- Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650)) -- Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681)) -- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699)) -- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879)) -- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806)) -- Marked all `RunAsync` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803)) -- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973)) NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file. diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs index cefcad323a..1b84f9f49f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentsOptions.cs @@ -141,4 +141,15 @@ public sealed class DurableAgentsOptions { return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive; } + + /// + /// Determines whether an agent with the specified name is registered. + /// + /// The name of the agent to locate. Cannot be null. + /// true if an agent with the specified name is registered; otherwise, false. + internal bool ContainsAgent(string agentName) + { + ArgumentNullException.ThrowIfNull(agentName); + return this._agentFactories.ContainsKey(agentName); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs new file mode 100644 index 0000000000..08dddf6852 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableDataConverter.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.DurableTask; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Custom data converter for durable agents and workflows that ensures proper JSON serialization. +/// +/// +/// This converter handles special cases like using source-generated +/// JSON contexts for AOT compatibility, and falls back to reflection-based serialization for other types. +/// +internal sealed class DurableDataConverter : DataConverter +{ + private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback uses reflection when metadata unavailable.")] + public override object? Deserialize(string? data, Type targetType) + { + if (data is null) + { + return null; + } + + if (targetType == typeof(DurableAgentState)) + { + return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState); + } + + JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType); + return typeInfo is not null + ? JsonSerializer.Deserialize(data, typeInfo) + : JsonSerializer.Deserialize(data, targetType, s_options); + } + + [return: NotNullIfNotNull(nameof(value))] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback uses reflection when metadata unavailable.")] + public override string? Serialize(object? value) + { + if (value is null) + { + return null; + } + + if (value is DurableAgentState durableAgentState) + { + return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState); + } + + JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType()); + return typeInfo is not null + ? JsonSerializer.Serialize(value, typeInfo) + : JsonSerializer.Serialize(value, s_options); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs new file mode 100644 index 0000000000..d7f289b223 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableOptions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.Agents.AI.DurableTask.Workflows; + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Provides configuration options for durable agents and workflows. +/// +[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")] +public class DurableOptions +{ + /// + /// Initializes a new instance of the class. + /// + internal DurableOptions() + { + this.Workflows = new DurableWorkflowOptions(this); + } + + /// + /// Gets the configuration options for durable agents. + /// + public DurableAgentsOptions Agents { get; } = new(); + + /// + /// Gets the configuration options for durable workflows. + /// + public DurableWorkflowOptions Workflows { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs new file mode 100644 index 0000000000..58dea9b20f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Marker class used to track whether core durable task services have been registered. +/// +/// +/// +/// Problem it solves: Users may call configuration methods multiple times: +/// +/// services.ConfigureDurableOptions(...); // 1st call - registers agent A +/// services.ConfigureDurableOptions(...); // 2nd call - registers workflow X +/// services.ConfigureDurableOptions(...); // 3rd call - registers agent B and workflow Y +/// +/// Each call invokes EnsureDurableServicesRegistered. Without this marker, core services like +/// AddDurableTaskWorker and AddDurableTaskClient would be registered multiple times, +/// causing runtime errors or unexpected behavior. +/// +/// +/// How it works: +/// +/// First call: No marker in services → register marker + all core services +/// Subsequent calls: Marker exists → early return, skip core service registration +/// +/// +/// +/// Why not use TryAddSingleton for everything? +/// While TryAddSingleton prevents duplicate simple service registrations, it doesn't work for +/// complex registrations like AddDurableTaskWorker which have side effects and configure +/// internal builders. The marker pattern provides a clean, explicit guard for the entire registration block. +/// +/// +internal sealed class DurableServicesMarker; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs index ba310441df..57ef010a2f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs @@ -100,4 +100,131 @@ internal static partial class Logs public static partial void LogTTLExpirationTimeCleared( this ILogger logger, AgentSessionId sessionId); + + // Durable workflow logs (EventIds 100-199) + + [LoggerMessage( + EventId = 100, + Level = LogLevel.Information, + Message = "Starting workflow '{WorkflowName}' with instance '{InstanceId}'")] + public static partial void LogWorkflowStarting( + this ILogger logger, + string workflowName, + string instanceId); + + [LoggerMessage( + EventId = 101, + Level = LogLevel.Information, + Message = "Superstep {Step}: {Count} active executor(s)")] + public static partial void LogSuperstepStarting( + this ILogger logger, + int step, + int count); + + [LoggerMessage( + EventId = 102, + Level = LogLevel.Debug, + Message = "Superstep {Step} executors: [{Executors}]")] + public static partial void LogSuperstepExecutors( + this ILogger logger, + int step, + string executors); + + [LoggerMessage( + EventId = 103, + Level = LogLevel.Information, + Message = "Workflow completed")] + public static partial void LogWorkflowCompleted( + this ILogger logger); + + [LoggerMessage( + EventId = 104, + Level = LogLevel.Warning, + Message = "Workflow '{InstanceId}' terminated early: reached maximum superstep limit ({MaxSupersteps}) with {RemainingExecutors} executor(s) still queued")] + public static partial void LogWorkflowMaxSuperstepsExceeded( + this ILogger logger, + string instanceId, + int maxSupersteps, + int remainingExecutors); + + [LoggerMessage( + EventId = 105, + Level = LogLevel.Debug, + Message = "Fan-In executor {ExecutorId}: aggregated {Count} messages from [{Sources}]")] + public static partial void LogFanInAggregated( + this ILogger logger, + string executorId, + int count, + string sources); + + [LoggerMessage( + EventId = 106, + Level = LogLevel.Debug, + Message = "Executor '{ExecutorId}' returned result (length: {Length}, messages: {MessageCount})")] + public static partial void LogExecutorResultReceived( + this ILogger logger, + string executorId, + int length, + int messageCount); + + [LoggerMessage( + EventId = 107, + Level = LogLevel.Debug, + Message = "Dispatching executor '{ExecutorId}' (agentic: {IsAgentic})")] + public static partial void LogDispatchingExecutor( + this ILogger logger, + string executorId, + bool isAgentic); + + [LoggerMessage( + EventId = 108, + Level = LogLevel.Warning, + Message = "Agent '{AgentName}' not found")] + public static partial void LogAgentNotFound( + this ILogger logger, + string agentName); + + [LoggerMessage( + EventId = 109, + Level = LogLevel.Debug, + Message = "Edge {Source} -> {Sink}: condition returned false, skipping")] + public static partial void LogEdgeConditionFalse( + this ILogger logger, + string source, + string sink); + + [LoggerMessage( + EventId = 110, + Level = LogLevel.Warning, + Message = "Failed to evaluate condition for edge {Source} -> {Sink}, skipping")] + public static partial void LogEdgeConditionEvaluationFailed( + this ILogger logger, + Exception ex, + string source, + string sink); + + [LoggerMessage( + EventId = 111, + Level = LogLevel.Debug, + Message = "Edge {Source} -> {Sink}: routing message")] + public static partial void LogEdgeRoutingMessage( + this ILogger logger, + string source, + string sink); + + [LoggerMessage( + EventId = 112, + Level = LogLevel.Information, + Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")] + public static partial void LogWaitingForExternalEvent( + this ILogger logger, + string requestPortId); + + [LoggerMessage( + EventId = 113, + Level = LogLevel.Information, + Message = "Received external event for RequestPort '{RequestPortId}'")] + public static partial void LogReceivedExternalEvent( + this ILogger logger, + string requestPortId); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj index 28046894db..77c877939e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj @@ -17,7 +17,6 @@ - true true @@ -28,6 +27,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index 79d44924ca..456e4ae98d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -1,18 +1,18 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Agents.AI.DurableTask.State; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Worker; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.DurableTask; /// -/// Agent-specific extension methods for the class. +/// Extension methods for configuring durable agents and workflows with dependency injection. /// public static class ServiceCollectionExtensions { @@ -30,77 +30,331 @@ public static class ServiceCollectionExtensions } /// - /// Configures the Durable Agents services via the service collection. + /// Configures durable agents, automatically registering agent entities. /// + /// + /// + /// This method provides an agent-focused configuration experience. + /// If you need to configure both agents and workflows, consider using + /// instead. + /// + /// + /// Multiple calls to this method are supported and configurations are composed additively. + /// + /// /// The service collection. /// A delegate to configure the durable agents. - /// A delegate to configure the Durable Task worker. - /// A delegate to configure the Durable Task client. - /// The service collection. + /// Optional delegate to configure the Durable Task worker. + /// Optional delegate to configure the Durable Task client. + /// The service collection for chaining. public static IServiceCollection ConfigureDurableAgents( this IServiceCollection services, Action configure, Action? workerBuilder = null, Action? clientBuilder = null) { + return services.ConfigureDurableOptions( + options => configure(options.Agents), + workerBuilder, + clientBuilder); + } + + /// + /// Configures durable workflows, automatically registering orchestrations and activities. + /// + /// + /// + /// This method provides a workflow-focused configuration experience. + /// If you need to configure both agents and workflows, consider using + /// instead. + /// + /// + /// Multiple calls to this method are supported and configurations are composed additively. + /// + /// + /// The service collection to configure. + /// A delegate to configure the workflow options. + /// Optional delegate to configure the durable task worker. + /// Optional delegate to configure the durable task client. + /// The service collection for chaining. + public static IServiceCollection ConfigureDurableWorkflows( + this IServiceCollection services, + Action configure, + Action? workerBuilder = null, + Action? clientBuilder = null) + { + return services.ConfigureDurableOptions( + options => configure(options.Workflows), + workerBuilder, + clientBuilder); + } + + /// + /// Configures durable agents and workflows, automatically registering orchestrations, activities, and agent entities. + /// + /// + /// + /// This is the recommended entry point for configuring durable functionality. It provides unified configuration + /// for both agents and workflows through a single instance, ensuring agents + /// referenced in workflows are automatically registered. + /// + /// + /// Multiple calls to this method (or to + /// and ) are supported and configurations are composed additively. + /// + /// + /// The service collection to configure. + /// A delegate to configure the durable options for both agents and workflows. + /// Optional delegate to configure the durable task worker. + /// Optional delegate to configure the durable task client. + /// The service collection for chaining. + /// + /// + /// services.ConfigureDurableOptions(options => + /// { + /// // Register agents not part of workflows + /// options.Agents.AddAIAgent(standaloneAgent); + /// + /// // Register workflows - agents in workflows are auto-registered + /// options.Workflows.AddWorkflow(myWorkflow); + /// }, + /// workerBuilder: builder => builder.UseDurableTaskScheduler(connectionString), + /// clientBuilder: builder => builder.UseDurableTaskScheduler(connectionString)); + /// + /// + public static IServiceCollection ConfigureDurableOptions( + this IServiceCollection services, + Action configure, + Action? workerBuilder = null, + Action? clientBuilder = null) + { + ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); - DurableAgentsOptions options = services.ConfigureDurableAgents(configure); + // Get or create the shared DurableOptions instance for configuration + DurableOptions sharedOptions = GetOrCreateSharedOptions(services); - // A worker is required to run the agent entities - services.AddDurableTaskWorker(builder => - { - workerBuilder?.Invoke(builder); + // Apply the configuration immediately to capture agent names for keyed service registration + configure(sharedOptions); - builder.AddTasks(registry => - { - foreach (string name in options.GetAgentFactories().Keys) - { - registry.AddEntity(AgentSessionId.ToEntityName(name)); - } - }); - }); + // Register keyed services for any new agents + RegisterAgentKeyedServices(services, sharedOptions); - // The client is needed to send notifications to the agent entities from non-orchestrator code - if (clientBuilder != null) - { - services.AddDurableTaskClient(clientBuilder); - } - - services.AddSingleton(); + // Register core services only once + EnsureDurableServicesRegistered(services, sharedOptions, workerBuilder, clientBuilder); return services; } - // This is internal because it's also used by Microsoft.Azure.Functions.DurableAgents, which is a friend assembly project. - internal static DurableAgentsOptions ConfigureDurableAgents( - this IServiceCollection services, - Action configure) + private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services) { - DurableAgentsOptions options = new(); - configure(options); + // Look for an existing DurableOptions registration + ServiceDescriptor? existingDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null); - IReadOnlyDictionary> agents = options.GetAgentFactories(); - - // The agent dictionary contains the real agent factories, which is used by the agent entities. - services.AddSingleton(agents); - - // Register the options so AgentEntity can access TTL configuration - services.AddSingleton(options); - - // The keyed services are used to resolve durable agent *proxy* instances for external clients. - foreach (var factory in agents) + if (existingDescriptor?.ImplementationInstance is DurableOptions existing) { - services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp)); + return existing; } - // A custom data converter is needed because the default chat client uses camel case for JSON properties, - // which is not the default behavior for the Durable Task SDK. - services.AddSingleton(); - + // Create a new shared options instance + DurableOptions options = new(); + services.AddSingleton(options); return options; } + private static void RegisterAgentKeyedServices(IServiceCollection services, DurableOptions options) + { + foreach (KeyValuePair> factory in options.Agents.GetAgentFactories()) + { + // Only add if not already registered (to support multiple Configure* calls) + if (!services.Any(d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService && Equals(d.ServiceKey, factory.Key))) + { + services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp)); + } + } + } + + /// + /// Ensures that the core durable services are registered only once, regardless of how many + /// times the configuration methods are called. + /// + private static void EnsureDurableServicesRegistered( + IServiceCollection services, + DurableOptions sharedOptions, + Action? workerBuilder, + Action? clientBuilder) + { + // Use a marker to ensure we only register core services once + if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker))) + { + return; + } + + services.AddSingleton(); + + services.TryAddSingleton(); + + // Configure Durable Task Worker - capture sharedOptions reference in closure. + // The options object is populated by all Configure* calls before the worker starts. + + if (workerBuilder is not null) + { + services.AddDurableTaskWorker(builder => + { + workerBuilder?.Invoke(builder); + + builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions)); + }); + } + + // Configure Durable Task Client + if (clientBuilder is not null) + { + services.AddDurableTaskClient(clientBuilder); + services.TryAddSingleton(); + services.TryAddSingleton(); + } + + // Register workflow and agent services + services.TryAddSingleton(); + + // Register agent factories resolver - returns factories from the shared options + services.TryAddSingleton( + sp => sp.GetRequiredService().Agents.GetAgentFactories()); + + // Register DurableAgentsOptions resolver + services.TryAddSingleton(sp => sp.GetRequiredService().Agents); + } + + private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions) + { + // Build registrations for all workflows including sub-workflows + List registrations = []; + HashSet registeredActivities = []; + HashSet registeredOrchestrations = []; + + DurableWorkflowOptions workflowOptions = durableOptions.Workflows; + foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList()) + { + BuildWorkflowRegistrationRecursive( + workflow, + workflowOptions, + registrations, + registeredActivities, + registeredOrchestrations); + } + + IReadOnlyDictionary> agentFactories = + durableOptions.Agents.GetAgentFactories(); + + // Register orchestrations and activities + foreach (WorkflowRegistrationInfo registration in registrations) + { + // Register with DurableWorkflowInput - the DataConverter handles serialization/deserialization + registry.AddOrchestratorFunc, DurableWorkflowResult>( + registration.OrchestrationName, + (context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions)); + + foreach (ActivityRegistrationInfo activity in registration.Activities) + { + ExecutorBinding binding = activity.Binding; + registry.AddActivityFunc( + activity.ActivityName, + (context, input) => DurableActivityExecutor.ExecuteAsync(binding, input)); + } + } + + // Register agent entities + foreach (string agentName in agentFactories.Keys) + { + registry.AddEntity(AgentSessionId.ToEntityName(agentName)); + } + } + + private static void BuildWorkflowRegistrationRecursive( + Workflow workflow, + DurableWorkflowOptions workflowOptions, + List registrations, + HashSet registeredActivities, + HashSet registeredOrchestrations) + { + string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!); + + if (!registeredOrchestrations.Add(orchestrationName)) + { + return; + } + + registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities)); + + // Process subworkflows recursively to register them as separate orchestrations + foreach (SubworkflowBinding subworkflowBinding in workflow.ReflectExecutors() + .Select(e => e.Value) + .OfType()) + { + Workflow subWorkflow = subworkflowBinding.WorkflowInstance; + workflowOptions.AddWorkflow(subWorkflow); + + BuildWorkflowRegistrationRecursive( + subWorkflow, + workflowOptions, + registrations, + registeredActivities, + registeredOrchestrations); + } + } + + private static WorkflowRegistrationInfo BuildWorkflowRegistration( + Workflow workflow, + HashSet registeredActivities) + { + string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!); + Dictionary executorBindings = workflow.ReflectExecutors(); + List activities = []; + + foreach (KeyValuePair entry in executorBindings + .Where(e => IsActivityBinding(e.Value))) + { + string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key); + string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); + + if (registeredActivities.Add(activityName)) + { + activities.Add(new ActivityRegistrationInfo(activityName, entry.Value)); + } + } + + return new WorkflowRegistrationInfo(orchestrationName, activities); + } + + /// + /// Returns for bindings that should be registered as Durable Task activities. + /// (Durable Entities), (sub-orchestrations), + /// and (human-in-the-loop via external events) use specialized dispatch + /// and are excluded. + /// + private static bool IsActivityBinding(ExecutorBinding binding) + => binding is not AIAgentBinding + and not SubworkflowBinding + and not RequestPortBinding; + + private static async Task RunWorkflowOrchestrationAsync( + TaskOrchestrationContext context, + DurableWorkflowInput workflowInput, + DurableOptions durableOptions) + { + ILogger logger = context.CreateReplaySafeLogger("DurableWorkflow"); + DurableWorkflowRunner runner = new(durableOptions); + + // ConfigureAwait(true) is required in orchestration code for deterministic replay. + return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true); + } + + private sealed record WorkflowRegistrationInfo(string OrchestrationName, List Activities); + + private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding); + /// /// Validates that an agent with the specified name has been registered. /// @@ -124,63 +378,4 @@ public static class ServiceCollectionExtensions throw new AgentNotRegisteredException(agentName); } } - - private sealed class DefaultDataConverter : DataConverter - { - // Use durable agent options (web defaults + camel case by default) with case-insensitive matching. - // We clone to apply naming/casing tweaks while retaining source-generated metadata where available. - private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions) - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")] - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")] - public override object? Deserialize(string? data, Type targetType) - { - if (data is null) - { - return null; - } - - if (targetType == typeof(DurableAgentState)) - { - return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState); - } - - JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType); - if (typeInfo is JsonTypeInfo typedInfo) - { - return JsonSerializer.Deserialize(data, typedInfo); - } - - // Fallback (may trigger trimming/AOT warnings for unsupported dynamic types). - return JsonSerializer.Deserialize(data, targetType, s_options); - } - - [return: NotNullIfNotNull(nameof(value))] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")] - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")] - public override string? Serialize(object? value) - { - if (value is null) - { - return null; - } - - if (value is DurableAgentState durableAgentState) - { - return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState); - } - - JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType()); - if (typeInfo is JsonTypeInfo typedInfo) - { - return JsonSerializer.Serialize(value, typedInfo); - } - - return JsonSerializer.Serialize(value, s_options); - } - } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs new file mode 100644 index 0000000000..c9e9a1b125 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.Observability; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Executes workflow activities by invoking executor bindings and handling serialization. +/// +[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Workflow and executor types are registered at startup.")] +[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Workflow and executor types are registered at startup.")] +[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")] +internal static class DurableActivityExecutor +{ + /// + /// Executes an activity using the provided executor binding. + /// + /// The executor binding to invoke. + /// The serialized input string. + /// A token to cancel the operation. + /// The serialized activity output. + /// Thrown when is null. + /// Thrown when the executor factory is not configured. + internal static async Task ExecuteAsync( + ExecutorBinding binding, + string input, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(binding); + + if (binding.FactoryAsync is null) + { + throw new InvalidOperationException($"Executor binding for '{binding.Id}' does not have a factory configured."); + } + + DurableActivityInput? inputWithState = TryDeserializeActivityInput(input); + string executorInput = inputWithState?.Input ?? input; + Dictionary sharedState = inputWithState?.State ?? []; + + Executor executor = await binding.FactoryAsync(binding.Id).ConfigureAwait(false); + Type inputType = ResolveInputType(inputWithState?.InputTypeName, executor.InputTypes); + object typedInput = DeserializeInput(executorInput, inputType); + + DurableWorkflowContext workflowContext = new(sharedState, executor); + object? result = await executor.ExecuteCoreAsync( + typedInput, + new TypeId(inputType), + workflowContext, + WorkflowTelemetryContext.Disabled, + cancellationToken).ConfigureAwait(false); + + return SerializeActivityOutput(result, workflowContext); + } + + private static string SerializeActivityOutput(object? result, DurableWorkflowContext context) + { + DurableExecutorOutput output = new() + { + Result = SerializeResult(result), + StateUpdates = context.StateUpdates, + ClearedScopes = [.. context.ClearedScopes], + Events = context.OutboundEvents.ConvertAll(SerializeEvent), + SentMessages = context.SentMessages, + HaltRequested = context.HaltRequested + }; + + return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + } + + /// + /// Serializes a workflow event with type information for proper deserialization. + /// + private static string SerializeEvent(WorkflowEvent evt) + { + Type eventType = evt.GetType(); + TypedPayload wrapper = new() + { + TypeName = eventType.AssemblyQualifiedName, + Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options) + }; + + return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload); + } + + private static string SerializeResult(object? result) + { + if (result is null) + { + return string.Empty; + } + + if (result is string str) + { + return str; + } + + return JsonSerializer.Serialize(result, result.GetType(), DurableSerialization.Options); + } + + private static DurableActivityInput? TryDeserializeActivityInput(string input) + { + try + { + return JsonSerializer.Deserialize(input, DurableWorkflowJsonContext.Default.DurableActivityInput); + } + catch (JsonException) + { + return null; + } + } + + internal static object DeserializeInput(string input, Type targetType) + { + if (targetType == typeof(string)) + { + return input; + } + + // Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]). + // When the target type is a non-string array, deserialize each element individually. + if (targetType.IsArray && targetType != typeof(string[])) + { + Type elementType = targetType.GetElementType()!; + string[]? stringArray = JsonSerializer.Deserialize(input, DurableSerialization.Options); + if (stringArray is not null) + { + Array result = Array.CreateInstance(elementType, stringArray.Length); + for (int i = 0; i < stringArray.Length; i++) + { + object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options) + ?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'."); + result.SetValue(element, i); + } + + return result; + } + } + + return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options) + ?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'."); + } + + internal static Type ResolveInputType(string? inputTypeName, ISet supportedTypes) + { + if (string.IsNullOrEmpty(inputTypeName)) + { + return supportedTypes.FirstOrDefault() ?? typeof(string); + } + + Type? matchedType = supportedTypes.FirstOrDefault(t => + t.AssemblyQualifiedName == inputTypeName || + t.FullName == inputTypeName || + t.Name == inputTypeName); + + if (matchedType is not null) + { + return matchedType; + } + + Type? loadedType = Type.GetType(inputTypeName); + + // Fall back if type is string or string[] but executor doesn't support it + if (loadedType is not null && !supportedTypes.Contains(loadedType)) + { + if (loadedType == typeof(string) || loadedType == typeof(string[])) + { + return supportedTypes.FirstOrDefault() ?? typeof(string); + } + } + + return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs new file mode 100644 index 0000000000..b49306bf9e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityInput.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Input payload for activity execution, containing the input and other metadata. +/// +internal sealed class DurableActivityInput +{ + /// + /// Gets or sets the serialized executor input. + /// + public string? Input { get; set; } + + /// + /// Gets or sets the assembly-qualified type name of the input, used for proper deserialization. + /// + public string? InputTypeName { get; set; } + + /// + /// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value). + /// + public Dictionary State { get; set; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs new file mode 100644 index 0000000000..b2440cfd83 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ConfigureAwait Usage in Orchestration Code: +// This file uses ConfigureAwait(true) because it runs within orchestration context. +// Durable Task orchestrations require deterministic replay - the same code must execute +// identically across replays. ConfigureAwait(true) ensures continuations run on the +// orchestration's synchronization context, which is essential for replay correctness. +// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay. + +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop). +/// +/// +/// Called during the dispatch phase of each superstep by +/// DurableWorkflowRunner.DispatchExecutorsInParallelAsync. For each executor that has +/// pending input, this dispatcher determines whether the executor is an AI agent (stateful, +/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events), +/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the +/// appropriate Durable Task API. +/// The serialised string result is returned to the runner for the routing phase. +/// +internal static class DurableExecutorDispatcher +{ + /// + /// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow). + /// + /// The task orchestration context. + /// Information about the executor to dispatch. + /// The message envelope containing input and type information. + /// The shared state dictionary to pass to the executor. + /// The live workflow status used to publish events and pending request port state. + /// The logger for tracing. + /// The result from the executor. + internal static async Task DispatchAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + DurableMessageEnvelope envelope, + Dictionary sharedState, + DurableWorkflowLiveStatus liveStatus, + ILogger logger) + { + logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor); + + if (executorInfo.IsRequestPortExecutor) + { + return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true); + } + + if (executorInfo.IsAgenticExecutor) + { + return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true); + } + + if (executorInfo.IsSubworkflowExecutor) + { + return await ExecuteSubWorkflowAsync(context, executorInfo, envelope.Message).ConfigureAwait(true); + } + + return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true); + } + + private static async Task ExecuteActivityAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + string input, + string? inputTypeName, + Dictionary sharedState) + { + string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId); + string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); + + DurableActivityInput activityInput = new() + { + Input = input, + InputTypeName = inputTypeName, + State = sharedState + }; + + string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput); + + return await context.CallActivityAsync(activityName, serializedInput).ConfigureAwait(true); + } + + /// + /// Executes a request port executor by waiting for an external event (human-in-the-loop). + /// + /// + /// When the workflow reaches a executor, the orchestration publishes + /// the pending request to and waits for an external actor + /// (e.g., a UI or API) to raise the corresponding event via + /// . + /// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep. + /// Each adds its pending request to . + /// The wait has no built-in timeout; for time-limited approvals, callers can combine + /// context.CreateTimer with Task.WhenAny in a wrapper executor. + /// + private static async Task ExecuteRequestPortAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + string input, + DurableWorkflowLiveStatus liveStatus, + ILogger logger) + { + RequestPort requestPort = executorInfo.RequestPort!; + string eventName = requestPort.Id; + + logger.LogWaitingForExternalEvent(eventName); + + // Publish pending request so external clients can discover what input is needed + liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input)); + context.SetCustomStatus(liveStatus); + + // Wait until the external actor raises the event + string response = await context.WaitForExternalEvent(eventName).ConfigureAwait(true); + + // Remove this pending request after receiving the response + liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName); + context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null); + + logger.LogReceivedExternalEvent(eventName); + + return response; + } + + /// + /// Executes an AI agent executor through Durable Entities. + /// + /// + /// AI agents are stateful and maintain conversation history. They use Durable Entities + /// to persist state across orchestration replays. + /// + private static async Task ExecuteAgentAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + ILogger logger, + string input) + { + string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId); + DurableAIAgent agent = context.GetAgent(agentName); + + if (agent is null) + { + logger.LogAgentNotFound(agentName); + return $"Agent '{agentName}' not found"; + } + + AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(true); + AgentResponse response = await agent.RunAsync(input, session).ConfigureAwait(true); + + return response.Text; + } + + /// + /// Dispatches a sub-workflow executor as a sub-orchestration. + /// + /// + /// Sub-workflows run as separate orchestration instances, providing independent + /// checkpointing, replay, and hierarchical visualization in the DTS dashboard. + /// The input is wrapped in so the sub-orchestration + /// can extract it using the same envelope structure. The sub-orchestration returns a + /// directly (deserialized by the Durable Task SDK), + /// which this method converts to a so the parent + /// workflow's result processing picks up both the result and any accumulated events. + /// + private static async Task ExecuteSubWorkflowAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + string input) + { + string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorInfo.SubWorkflow!.Name!); + + DurableWorkflowInput workflowInput = new() { Input = input }; + + DurableWorkflowResult? workflowResult = await context.CallSubOrchestratorAsync( + orchestrationName, + workflowInput).ConfigureAwait(true); + + return ConvertWorkflowResultToExecutorOutput(workflowResult); + } + + /// + /// Converts a from a sub-orchestration + /// into a JSON string. This bridges the sub-workflow's + /// output format to the parent workflow's result processing, preserving both the result + /// and any accumulated events from the sub-workflow. + /// + private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult) + { + if (workflowResult is null) + { + return string.Empty; + } + + // Propagate the result, events, and sent messages from the sub-workflow. + // SentMessages carry the sub-workflow's output for typed routing in the parent, + // matching the in-process WorkflowHostExecutor behavior. + // Shared state is not included because each workflow instance maintains its own + // independent shared state; it is not shared between parent and sub-workflows. + DurableExecutorOutput executorOutput = new() + { + Result = workflowResult.Result, + Events = workflowResult.Events ?? [], + SentMessages = workflowResult.SentMessages ?? [], + HaltRequested = workflowResult.HaltRequested, + }; + + return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs new file mode 100644 index 0000000000..ce3f26c14b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorOutput.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Output payload from executor execution, containing the result, state updates, and emitted events. +/// +internal sealed class DurableExecutorOutput +{ + /// + /// Gets the executor result. + /// + public string? Result { get; init; } + + /// + /// Gets the state updates (scope-prefixed key to value; null indicates deletion). + /// + public Dictionary StateUpdates { get; init; } = []; + + /// + /// Gets the scope names that were cleared. + /// + public List ClearedScopes { get; init; } = []; + + /// + /// Gets the workflow events emitted during execution. + /// + public List Events { get; init; } = []; + + /// + /// Gets the typed messages sent to downstream executors. + /// + public List SentMessages { get; init; } = []; + + /// + /// Gets a value indicating whether the executor requested a workflow halt. + /// + public bool HaltRequested { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs new file mode 100644 index 0000000000..6c7aacfc48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableHaltRequestedEvent.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Event raised when an executor requests the workflow to halt via . +/// +public sealed class DurableHaltRequestedEvent : WorkflowEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the executor that requested the halt. + public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}") + { + this.ExecutorId = executorId; + } + + /// + /// Gets the ID of the executor that requested the halt. + /// + public string ExecutorId { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs new file mode 100644 index 0000000000..56f560a31c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableMessageEnvelope.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a message envelope for durable workflow message passing. +/// +/// +/// +/// This is the durable equivalent of MessageEnvelope in the in-process runner. +/// Unlike the in-process version which holds native .NET objects, this envelope +/// contains serialized JSON strings suitable for Durable Task activities. +/// +/// +internal sealed class DurableMessageEnvelope +{ + /// + /// Gets or sets the serialized JSON message content. + /// + public required string Message { get; init; } + + /// + /// Gets or sets the full type name of the message for deserialization. + /// + public string? InputTypeName { get; init; } + + /// + /// Gets or sets the ID of the executor that produced this message. + /// + /// + /// Used for tracing and debugging. Null for initial workflow input. + /// + public string? SourceExecutorId { get; init; } + + /// + /// Creates a new message envelope. + /// + /// The serialized JSON message content. + /// The full type name of the message for deserialization. + /// The ID of the executor that produced this message, or null for initial input. + /// A new instance. + internal static DurableMessageEnvelope Create(string message, string? inputTypeName, string? sourceExecutorId = null) + { + return new DurableMessageEnvelope + { + Message = message, + InputTypeName = inputTypeName, + SourceExecutorId = sourceExecutorId + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs new file mode 100644 index 0000000000..cff00a84ca --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableRunStatus.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents the execution status of a durable workflow run. +/// +public enum DurableRunStatus +{ + /// + /// The workflow instance was not found. + /// + NotFound, + + /// + /// The workflow is pending and has not started. + /// + Pending, + + /// + /// The workflow is currently running. + /// + Running, + + /// + /// The workflow completed successfully. + /// + Completed, + + /// + /// The workflow failed with an error. + /// + Failed, + + /// + /// The workflow was terminated. + /// + Terminated, + + /// + /// The workflow is suspended. + /// + Suspended, + + /// + /// The workflow status is unknown. + /// + Unknown +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs new file mode 100644 index 0000000000..245ec36fb8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Shared serialization options for user-defined workflow types that are not known at compile time +/// and therefore cannot use the source-generated . +/// +internal static class DurableSerialization +{ + /// + /// Gets the shared for workflow serialization + /// with camelCase naming and case-insensitive deserialization. + /// + internal static JsonSerializerOptions Options { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs new file mode 100644 index 0000000000..6cacf871e0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a durable workflow run that supports streaming workflow events as they occur. +/// +/// +/// +/// Events are detected by monitoring the orchestration's custom status at regular intervals. +/// When executors emit events via or +/// , they are written to the orchestration's +/// custom status and picked up by this streaming run. +/// +/// +/// When the workflow reaches a executor, a +/// is yielded containing the request data. The caller should then call +/// +/// to provide the response and resume the workflow. +/// +/// +[DebuggerDisplay("{WorkflowName} ({RunId})")] +internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun +{ + private readonly DurableTaskClient _client; + private readonly Dictionary _requestPorts; + + /// + /// Initializes a new instance of the class. + /// + /// The durable task client for orchestration operations. + /// The unique instance ID for this orchestration run. + /// The workflow being executed. + internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow) + { + this._client = client; + this.RunId = instanceId; + this.WorkflowName = workflow.Name ?? string.Empty; + this._requestPorts = ExtractRequestPorts(workflow); + } + + /// + public string RunId { get; } + + /// + /// Gets the name of the workflow being executed. + /// + public string WorkflowName { get; } + + /// + /// Gets the current execution status of the workflow run. + /// + /// A cancellation token to observe. + /// The current status of the durable run. + public async ValueTask GetStatusAsync(CancellationToken cancellationToken = default) + { + OrchestrationMetadata? metadata = await this._client.GetInstanceAsync( + this.RunId, + getInputsAndOutputs: false, + cancellation: cancellationToken).ConfigureAwait(false); + + if (metadata is null) + { + return DurableRunStatus.NotFound; + } + + return metadata.RuntimeStatus switch + { + OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending, + OrchestrationRuntimeStatus.Running => DurableRunStatus.Running, + OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed, + OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed, + OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated, + OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended, + _ => DurableRunStatus.Unknown + }; + } + + /// + public IAsyncEnumerable WatchStreamAsync(CancellationToken cancellationToken = default) + => this.WatchStreamAsync(pollingInterval: null, cancellationToken); + + /// + /// Asynchronously streams workflow events as they occur during workflow execution. + /// + /// The interval between status checks. Defaults to 100ms. + /// A cancellation token to observe. + /// An asynchronous stream of objects. + private async IAsyncEnumerable WatchStreamAsync( + TimeSpan? pollingInterval, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + TimeSpan minInterval = pollingInterval ?? TimeSpan.FromMilliseconds(100); + TimeSpan maxInterval = TimeSpan.FromSeconds(2); + TimeSpan currentInterval = minInterval; + + // Track how many events we've already read from the durable workflow status + int lastReadEventIndex = 0; + + // Track which pending events we've already yielded to avoid duplicates + HashSet yieldedPendingEvents = []; + + while (!cancellationToken.IsCancellationRequested) + { + // Poll with getInputsAndOutputs: true because SerializedCustomStatus + // (used for event streaming) is only populated when this flag is set. + OrchestrationMetadata? metadata = await this._client.GetInstanceAsync( + this.RunId, + getInputsAndOutputs: true, + cancellation: cancellationToken).ConfigureAwait(false); + + if (metadata is null) + { + yield break; + } + + bool hasNewEvents = false; + + // Always drain any unread events from the durable workflow status before checking terminal states. + // The orchestration may complete before the next poll, so events would be lost if we + // check terminal status first. + if (metadata.SerializedCustomStatus is not null) + { + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)) + { + (List events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex); + foreach (WorkflowEvent evt in events) + { + hasNewEvents = true; + yield return evt; + } + + // Yield a DurableWorkflowWaitingForInputEvent for each new pending request port + foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents) + { + if (yieldedPendingEvents.Add(pending.EventName)) + { + if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort)) + { + // RequestPort may not exist in the current workflow definition (e.g., during rolling deployments). + continue; + } + + hasNewEvents = true; + yield return new DurableWorkflowWaitingForInputEvent( + pending.Input, + matchingPort); + } + } + + // Sync tracking with current pending events so re-used RequestPort names can be yielded again + if (liveStatus.PendingEvents.Count == 0) + { + yieldedPendingEvents.Clear(); + } + else + { + yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName)); + } + } + } + + // Check terminal states after draining events from the durable workflow status + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) + { + // The framework clears the durable workflow status on completion, so events may be in + // SerializedOutput as a DurableWorkflowResult wrapper. + if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult)) + { + (List events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex); + foreach (WorkflowEvent evt in events) + { + yield return evt; + } + + yield return new DurableWorkflowCompletedEvent(outputResult.Result); + } + else + { + // The runner always wraps output in DurableWorkflowResult, so a parse + // failure here indicates a bug. Yield a failed event so the consumer + // gets a visible, handleable signal without crashing. + yield return new DurableWorkflowFailedEvent( + $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) completed but its output could not be parsed as DurableWorkflowResult."); + } + + yield break; + } + + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed) + { + string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed."; + yield return new DurableWorkflowFailedEvent(errorMessage, metadata.FailureDetails); + yield break; + } + + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated) + { + yield return new DurableWorkflowFailedEvent("Workflow was terminated."); + yield break; + } + + // Adaptive backoff: reset to minimum when events were found, increase otherwise + currentInterval = hasNewEvents + ? minInterval + : TimeSpan.FromMilliseconds(Math.Min(currentInterval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds)); + + try + { + await Task.Delay(currentInterval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + yield break; + } + } + } + + /// + /// Sends a response to a to resume the workflow. + /// + /// The type of the response data. + /// The request event to respond to. + /// The response data to send. + /// A cancellation token to observe. + /// A representing the asynchronous operation. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")] + public async ValueTask SendResponseAsync(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requestEvent); + + string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options); + await this._client.RaiseEventAsync( + this.RunId, + requestEvent.RequestPort.Id, + serializedResponse, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Waits for the workflow to complete and returns the result. + /// + /// The expected result type. + /// A cancellation token to observe. + /// The result of the workflow execution. + /// Thrown when the workflow failed. + /// Thrown when the workflow was terminated or ended with an unexpected status. + public async ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) + { + OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync( + this.RunId, + getInputsAndOutputs: true, + cancellation: cancellationToken).ConfigureAwait(false); + + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) + { + return ExtractResult(metadata.SerializedOutput); + } + + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed) + { + if (metadata.FailureDetails is not null) + { + throw new TaskFailedException( + taskName: this.WorkflowName, + taskId: -1, + failureDetails: metadata.FailureDetails); + } + + throw new InvalidOperationException( + $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details."); + } + + throw new InvalidOperationException( + $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}"); + } + + /// + /// Deserializes and returns any events beyond from the list. + /// + private static (List Events, int UpdatedIndex) DrainNewEvents(List serializedEvents, int lastReadIndex) + { + List events = []; + while (lastReadIndex < serializedEvents.Count) + { + string serializedEvent = serializedEvents[lastReadIndex]; + lastReadIndex++; + + WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent); + if (workflowEvent is not null) + { + events.Add(workflowEvent); + } + } + + return (events, lastReadIndex); + } + + /// + /// Attempts to parse the orchestration output as a wrapper. + /// + /// + /// The orchestration returns a object directly. + /// The Durable Task framework's DataConverter serializes it as a JSON object + /// in SerializedOutput, so we deserialize it directly. + /// + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")] + private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhen(true)] out DurableWorkflowResult? result) + { + if (serializedOutput is null) + { + result = default!; + return false; + } + + try + { + result = JsonSerializer.Deserialize(serializedOutput, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!; + return result is not null; + } + catch (JsonException) + { + result = default!; + return false; + } + } + + /// + /// Extracts a typed result from the orchestration output by unwrapping the + /// wrapper. + /// + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")] + internal static TResult? ExtractResult(string? serializedOutput) + { + if (serializedOutput is null) + { + return default; + } + + if (!TryParseWorkflowResult(serializedOutput, out DurableWorkflowResult? workflowResult)) + { + throw new InvalidOperationException( + "Failed to parse orchestration output as DurableWorkflowResult. " + + "The orchestration runner should always wrap output in this format."); + } + + string? resultJson = workflowResult.Result; + + if (resultJson is null) + { + return default; + } + + if (typeof(TResult) == typeof(string)) + { + return (TResult)(object)resultJson; + } + + return JsonSerializer.Deserialize(resultJson, DurableSerialization.Options); + } + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")] + [UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup.")] + private static WorkflowEvent? TryDeserializeEvent(string serializedEvent) + { + try + { + TypedPayload? wrapper = JsonSerializer.Deserialize( + serializedEvent, + DurableWorkflowJsonContext.Default.TypedPayload); + + if (wrapper?.TypeName is not null && wrapper.Data is not null) + { + Type? eventType = Type.GetType(wrapper.TypeName); + if (eventType is not null) + { + return DeserializeEventByType(eventType, wrapper.Data); + } + } + + return null; + } + catch (JsonException) + { + return null; + } + } + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")] + private static WorkflowEvent? DeserializeEventByType(Type eventType, string json) + { + // Types with internal constructors need manual deserialization + if (eventType == typeof(ExecutorInvokedEvent) + || eventType == typeof(ExecutorCompletedEvent) + || eventType == typeof(WorkflowOutputEvent)) + { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + if (eventType == typeof(ExecutorInvokedEvent)) + { + string executorId = root.GetProperty("executorId").GetString() ?? string.Empty; + JsonElement? data = GetDataProperty(root); + return new ExecutorInvokedEvent(executorId, data!); + } + + if (eventType == typeof(ExecutorCompletedEvent)) + { + string executorId = root.GetProperty("executorId").GetString() ?? string.Empty; + JsonElement? data = GetDataProperty(root); + return new ExecutorCompletedEvent(executorId, data); + } + + // WorkflowOutputEvent + string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty; + object? outputData = GetDataProperty(root); + return new WorkflowOutputEvent(outputData!, sourceId); + } + + return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent; + } + + private static JsonElement? GetDataProperty(JsonElement root) + { + if (!root.TryGetProperty("data", out JsonElement dataElement)) + { + return null; + } + + return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone(); + } + + private static Dictionary ExtractRequestPorts(Workflow workflow) + { + return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow) + .Where(e => e.RequestPort is not null) + .ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs new file mode 100644 index 0000000000..5944d578ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Provides a durable task-based implementation of for running +/// workflows as durable orchestrations. +/// +internal sealed class DurableWorkflowClient : IWorkflowClient +{ + private readonly DurableTaskClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The durable task client for orchestration operations. + /// Thrown when is null. + public DurableWorkflowClient(DurableTaskClient client) + { + ArgumentNullException.ThrowIfNull(client); + this._client = client; + } + + /// + public async ValueTask RunAsync( + Workflow workflow, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull + { + ArgumentNullException.ThrowIfNull(workflow); + + if (string.IsNullOrEmpty(workflow.Name)) + { + throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow)); + } + + DurableWorkflowInput workflowInput = new() { Input = input }; + + string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync( + orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name), + input: workflowInput, + options: runId is not null ? new StartOrchestrationOptions(runId) : null, + cancellation: cancellationToken).ConfigureAwait(false); + + return new DurableWorkflowRun(this._client, instanceId, workflow.Name); + } + + /// + public ValueTask RunAsync( + Workflow workflow, + string input, + string? runId = null, + CancellationToken cancellationToken = default) + => this.RunAsync(workflow, input, runId, cancellationToken); + + /// + public async ValueTask StreamAsync( + Workflow workflow, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull + { + ArgumentNullException.ThrowIfNull(workflow); + + if (string.IsNullOrEmpty(workflow.Name)) + { + throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow)); + } + + DurableWorkflowInput workflowInput = new() { Input = input }; + + string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync( + orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name), + input: workflowInput, + options: runId is not null ? new StartOrchestrationOptions(runId) : null, + cancellation: cancellationToken).ConfigureAwait(false); + + return new DurableStreamingWorkflowRun(this._client, instanceId, workflow); + } + + /// + public ValueTask StreamAsync( + Workflow workflow, + string input, + string? runId = null, + CancellationToken cancellationToken = default) + => this.StreamAsync(workflow, input, runId, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs new file mode 100644 index 0000000000..a4de6d1d50 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowCompletedEvent.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Event raised when a durable workflow completes successfully. +/// +[DebuggerDisplay("Completed: {Result}")] +public sealed class DurableWorkflowCompletedEvent : WorkflowEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The serialized result of the workflow. + public DurableWorkflowCompletedEvent(string? result) : base(result) + { + this.Result = result; + } + + /// + /// Gets the serialized result of the workflow. + /// + public string? Result { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs new file mode 100644 index 0000000000..5f98f5dc59 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowContext.cs @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// A workflow context for durable workflow execution. +/// +/// +/// State is passed in from the orchestration and updates are collected for return. +/// Events emitted during execution are collected and returned to the orchestration +/// as part of the activity output for streaming to callers. +/// +[DebuggerDisplay("Executor = {_executor.Id}, StateEntries = {_initialState.Count}")] +internal sealed class DurableWorkflowContext : IWorkflowContext +{ + /// + /// The default scope name used when no explicit scope is specified. + /// Scopes partition shared state into logical namespaces so that different + /// parts of a workflow can manage their state keys independently. + /// + private const string DefaultScopeName = "__default__"; + + private readonly Dictionary _initialState; + private readonly Executor _executor; + + /// + /// Initializes a new instance of the class. + /// + /// The shared state passed from the orchestration. + /// The executor running in this context. + internal DurableWorkflowContext(Dictionary? initialState, Executor executor) + { + this._executor = executor; + this._initialState = initialState ?? []; + } + + /// + /// Gets the messages sent during activity execution via . + /// + internal List SentMessages { get; } = []; + + /// + /// Gets the outbound events that were added during activity execution. + /// + internal List OutboundEvents { get; } = []; + + /// + /// Gets the state updates made during activity execution. + /// + internal Dictionary StateUpdates { get; } = []; + + /// + /// Gets the scopes that were cleared during activity execution. + /// + internal HashSet ClearedScopes { get; } = []; + + /// + /// Gets a value indicating whether the executor requested a workflow halt. + /// + internal bool HaltRequested { get; private set; } + + /// + public ValueTask AddEventAsync( + WorkflowEvent workflowEvent, + CancellationToken cancellationToken = default) + { + if (workflowEvent is not null) + { + this.OutboundEvents.Add(workflowEvent); + } + + return default; + } + + /// + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow message types registered at startup.")] + public ValueTask SendMessageAsync( + object message, + string? targetId = null, + CancellationToken cancellationToken = default) + { + if (message is not null) + { + Type messageType = message.GetType(); + this.SentMessages.Add(new TypedPayload + { + Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options), + TypeName = messageType.AssemblyQualifiedName + }); + } + + return default; + } + + /// + public ValueTask YieldOutputAsync( + object output, + CancellationToken cancellationToken = default) + { + if (output is not null) + { + Type outputType = output.GetType(); + if (!this._executor.CanOutput(outputType)) + { + throw new InvalidOperationException( + $"Cannot output object of type {outputType.Name}. " + + $"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}]."); + } + + this.OutboundEvents.Add(new WorkflowOutputEvent(output, this._executor.Id)); + } + + return default; + } + + /// + public ValueTask RequestHaltAsync() + { + this.HaltRequested = true; + this.OutboundEvents.Add(new DurableHaltRequestedEvent(this._executor.Id)); + return default; + } + + /// + public ValueTask ReadStateAsync( + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + string scopeKey = GetScopeKey(scopeName, key); + string normalizedScope = scopeName ?? DefaultScopeName; + bool scopeCleared = this.ClearedScopes.Contains(normalizedScope); + + // Local updates take priority over initial state. + if (this.StateUpdates.TryGetValue(scopeKey, out string? updated)) + { + return DeserializeStateAsync(updated); + } + + // If scope was cleared, ignore initial state + if (scopeCleared) + { + return ValueTask.FromResult(default); + } + + // Fall back to initial state passed from orchestration + if (this._initialState.TryGetValue(scopeKey, out string? initial)) + { + return DeserializeStateAsync(initial); + } + + return ValueTask.FromResult(default); + } + + /// + public async ValueTask ReadOrInitStateAsync( + string key, + Func initialStateFactory, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentNullException.ThrowIfNull(initialStateFactory); + + // Cannot rely on `value is not null` because T? on an unconstrained generic + // parameter does not become Nullable for value types — the null check is + // always true for types like int. Instead, check key existence directly. + if (this.HasStateKey(key, scopeName)) + { + T? value = await this.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + if (value is not null) + { + return value; + } + } + + T initialValue = initialStateFactory(); + await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false); + return initialValue; + } + + /// + public ValueTask> ReadStateKeysAsync( + string? scopeName = null, + CancellationToken cancellationToken = default) + { + string scopePrefix = GetScopePrefix(scopeName); + int scopePrefixLength = scopePrefix.Length; + HashSet keys = new(StringComparer.Ordinal); + + bool scopeCleared = scopeName is null + ? this.ClearedScopes.Contains(DefaultScopeName) + : this.ClearedScopes.Contains(scopeName); + + // Start with keys from initial state (skip if scope was cleared) + if (!scopeCleared) + { + foreach (string stateKey in this._initialState.Keys) + { + if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal)) + { + keys.Add(stateKey[scopePrefixLength..]); + } + } + } + + // Merge local updates: add if non-null, remove if null (deleted) + foreach (KeyValuePair update in this.StateUpdates) + { + if (!update.Key.StartsWith(scopePrefix, StringComparison.Ordinal)) + { + continue; + } + + string key = update.Key[scopePrefixLength..]; + if (update.Value is not null) + { + keys.Add(key); + } + else + { + keys.Remove(key); + } + } + + return ValueTask.FromResult(keys); + } + + /// + public ValueTask QueueStateUpdateAsync( + string key, + T? value, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + string scopeKey = GetScopeKey(scopeName, key); + this.StateUpdates[scopeKey] = value is null ? null : SerializeState(value); + return default; + } + + /// + public ValueTask QueueClearScopeAsync( + string? scopeName = null, + CancellationToken cancellationToken = default) + { + this.ClearedScopes.Add(scopeName ?? DefaultScopeName); + + // Remove any pending updates in this scope (snapshot keys to allow removal during iteration) + string scopePrefix = GetScopePrefix(scopeName); + foreach (string key in this.StateUpdates.Keys.ToList()) + { + if (key.StartsWith(scopePrefix, StringComparison.Ordinal)) + { + this.StateUpdates.Remove(key); + } + } + + return default; + } + + /// + public IReadOnlyDictionary? TraceContext => null; + + /// + public bool ConcurrentRunsEnabled => false; + + private static string GetScopeKey(string? scopeName, string key) + => $"{GetScopePrefix(scopeName)}{key}"; + + /// + /// Checks whether the given key exists in local updates or initial state, + /// respecting cleared scopes. + /// + private bool HasStateKey(string key, string? scopeName) + { + string scopeKey = GetScopeKey(scopeName, key); + + if (this.StateUpdates.TryGetValue(scopeKey, out string? updated)) + { + return updated is not null; + } + + string normalizedScope = scopeName ?? DefaultScopeName; + if (this.ClearedScopes.Contains(normalizedScope)) + { + return false; + } + + return this._initialState.ContainsKey(scopeKey); + } + + /// + /// Returns the key prefix for the given scope. Scopes partition shared state + /// into logical namespaces, allowing different workflow executors to manage + /// their state keys independently. When no scope is specified, the + /// is used. + /// + private static string GetScopePrefix(string? scopeName) + => scopeName is null ? $"{DefaultScopeName}:" : $"{scopeName}:"; + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")] + private static string SerializeState(T value) + => JsonSerializer.Serialize(value, DurableSerialization.Options); + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")] + private static ValueTask DeserializeStateAsync(string? json) + { + if (json is null) + { + return ValueTask.FromResult(default); + } + + return ValueTask.FromResult(JsonSerializer.Deserialize(json, DurableSerialization.Options)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs new file mode 100644 index 0000000000..4f1e411be6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowFailedEvent.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Event raised when a durable workflow fails. +/// +[DebuggerDisplay("Failed: {ErrorMessage}")] +public sealed class DurableWorkflowFailedEvent : WorkflowEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message describing the failure. + /// The full failure details from the Durable Task runtime, if available. + public DurableWorkflowFailedEvent(string errorMessage, TaskFailureDetails? failureDetails = null) : base(errorMessage) + { + this.ErrorMessage = errorMessage; + this.FailureDetails = failureDetails; + } + + /// + /// Gets the error message describing the failure. + /// + public string ErrorMessage { get; } + + /// + /// Gets the full failure details from the Durable Task runtime, including error type, stack trace, and inner failure. + /// + public TaskFailureDetails? FailureDetails { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs new file mode 100644 index 0000000000..bd6f42f501 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowInput.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents the input envelope for a durable workflow orchestration. +/// +/// The type of the workflow input. +internal sealed class DurableWorkflowInput + where TInput : notnull +{ + /// + /// Gets the workflow input data. + /// + public required TInput Input { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs new file mode 100644 index 0000000000..12f4c490b9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Source-generated JSON serialization context for durable workflow types. +/// +/// +/// +/// This context provides AOT-compatible and trimmer-safe JSON serialization for the +/// internal data transfer types used by the durable workflow infrastructure: +/// +/// +/// : Activity input wrapper with state +/// : Executor output wrapper with results, events, and state updates +/// : Serialized payload wrapper with type info (events and messages) +/// : Live status payload (streaming events and pending request ports) +/// +/// +/// Note: User-defined executor input/output types still use reflection-based serialization +/// since their types are not known at compile time. +/// +/// +[JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(DurableActivityInput))] +[JsonSerializable(typeof(DurableExecutorOutput))] +[JsonSerializable(typeof(TypedPayload))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DurableWorkflowLiveStatus))] +[JsonSerializable(typeof(DurableWorkflowResult))] +[JsonSerializable(typeof(PendingRequestPortStatus))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +internal partial class DurableWorkflowJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs new file mode 100644 index 0000000000..5e381ce0eb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowLiveStatus.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Live status payload written to the orchestration via SetCustomStatus. +/// +/// +/// +/// This is the only orchestration state readable by external clients while the workflow +/// is still running. It is written after each superstep so that +/// can poll for new events. +/// On completion the framework clears it, so events are also +/// embedded in the output via . +/// +/// +/// When the workflow is paused at one or more nodes, +/// contains the request data for each. +/// +/// +internal sealed class DurableWorkflowLiveStatus +{ + /// + /// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed. + /// + public List PendingEvents { get; set; } = []; + + /// + /// Gets or sets the serialized workflow events emitted so far. + /// + public List Events { get; set; } = []; + + /// + /// Attempts to deserialize a serialized custom status string into a . + /// + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")] + internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result) + { + if (serializedStatus is null) + { + result = default!; + return false; + } + + try + { + result = System.Text.Json.JsonSerializer.Deserialize(serializedStatus, DurableSerialization.Options)!; + return result is not null; + } + catch (System.Text.Json.JsonException) + { + result = default!; + return false; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs new file mode 100644 index 0000000000..67a21c9100 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Provides configuration options for managing durable workflows within an application. +/// +[DebuggerDisplay("Workflows = {Workflows.Count}")] +public sealed class DurableWorkflowOptions +{ + private readonly Dictionary _workflows = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// Optional parent options container for accessing related configuration. + internal DurableWorkflowOptions(DurableOptions? parentOptions = null) + { + this.ParentOptions = parentOptions; + } + + /// + /// Gets the parent container, if available. + /// + internal DurableOptions? ParentOptions { get; } + + /// + /// Gets the collection of workflows available in the current context, keyed by their unique names. + /// + public IReadOnlyDictionary Workflows => this._workflows; + + /// + /// Gets the executor registry for direct executor lookup. + /// + internal ExecutorRegistry Executors { get; } = new(); + + /// + /// Adds a workflow to the collection for processing or execution. + /// + /// The workflow instance to add. Cannot be null. + /// + /// When a workflow is added, all executors are registered in the executor registry. + /// Any AI agent executors will also be automatically registered with the + /// if available. + /// + /// Thrown when is null. + /// Thrown when the workflow does not have a valid name. + public void AddWorkflow(Workflow workflow) + { + ArgumentNullException.ThrowIfNull(workflow); + + if (string.IsNullOrEmpty(workflow.Name)) + { + throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow)); + } + + this._workflows[workflow.Name] = workflow; + this.RegisterWorkflowExecutors(workflow); + } + + /// + /// Adds a collection of workflows to the current instance. + /// + /// The collection of objects to add. + /// Thrown when is null. + public void AddWorkflows(params Workflow[] workflows) + { + ArgumentNullException.ThrowIfNull(workflows); + + foreach (Workflow workflow in workflows) + { + this.AddWorkflow(workflow); + } + } + + /// + /// Registers all executors from a workflow, including AI agents if agent options are available. + /// + private void RegisterWorkflowExecutors(Workflow workflow) + { + DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents; + + foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors()) + { + string executorName = WorkflowNamingHelper.GetExecutorName(executorId); + this.Executors.Register(executorName, executorId, workflow); + + TryRegisterAgent(binding, agentOptions); + } + } + + /// + /// Registers an AI agent with the agent options if the binding contains an unregistered agent. + /// + private static void TryRegisterAgent(ExecutorBinding binding, DurableAgentsOptions? agentOptions) + { + if (agentOptions is null) + { + return; + } + + if (binding.RawValue is AIAgent { Name: not null } agent + && !agentOptions.ContainsAgent(agent.Name)) + { + agentOptions.AddAIAgent(agent); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs new file mode 100644 index 0000000000..7f63232185 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Wraps the orchestration output to include both the workflow result and accumulated events. +/// +/// +/// The Durable Task framework clears SerializedCustomStatus when an orchestration +/// completes. To ensure streaming clients can retrieve events even after completion, +/// the accumulated events are embedded in the orchestration output alongside the result. +/// +internal sealed class DurableWorkflowResult +{ + /// + /// Gets or sets the serialized result of the workflow execution. + /// + public string? Result { get; set; } + + /// + /// Gets or sets the serialized workflow events emitted during execution. + /// + public List Events { get; set; } = []; + + /// + /// Gets or sets the typed messages to forward to connected executors in the parent workflow. + /// + /// + /// When this workflow runs as a sub-orchestration, these messages are propagated to the + /// parent workflow and routed to successor executors via the edge map. + /// + public List SentMessages { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the workflow was halted by an executor. + /// + /// + /// When this workflow runs as a sub-orchestration, this flag is propagated to the + /// parent workflow so halt semantics are preserved across nesting levels. + /// + public bool HaltRequested { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs new file mode 100644 index 0000000000..aeb42f4fb6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRun.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a durable workflow run that tracks execution status and provides access to workflow events. +/// +[DebuggerDisplay("{WorkflowName} ({RunId})")] +internal sealed class DurableWorkflowRun : IAwaitableWorkflowRun +{ + private readonly DurableTaskClient _client; + private readonly List _eventSink = []; + private int _lastBookmark; + + /// + /// Initializes a new instance of the class. + /// + /// The durable task client for orchestration operations. + /// The unique instance ID for this orchestration run. + /// The name of the workflow being executed. + internal DurableWorkflowRun(DurableTaskClient client, string instanceId, string workflowName) + { + this._client = client; + this.RunId = instanceId; + this.WorkflowName = workflowName; + } + + /// + public string RunId { get; } + + /// + /// Gets the name of the workflow being executed. + /// + public string WorkflowName { get; } + + /// + /// Waits for the workflow to complete and returns the result. + /// + /// The expected result type. + /// A cancellation token to observe. + /// The result of the workflow execution. + /// Thrown when the workflow failed. + /// Thrown when the workflow was terminated or ended with an unexpected status. + public async ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) + { + OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync( + this.RunId, + getInputsAndOutputs: true, + cancellation: cancellationToken).ConfigureAwait(false); + + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) + { + return DurableStreamingWorkflowRun.ExtractResult(metadata.SerializedOutput); + } + + if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed) + { + if (metadata.FailureDetails is not null) + { + // Use TaskFailedException to preserve full failure details including stack trace and inner exceptions + throw new TaskFailedException( + taskName: this.WorkflowName, + taskId: 0, + failureDetails: metadata.FailureDetails); + } + + throw new InvalidOperationException( + $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details."); + } + + throw new InvalidOperationException( + $"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}"); + } + + /// + /// Waits for the workflow to complete and returns the string result. + /// + /// A cancellation token to observe. + /// The string result of the workflow execution. + public ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) + => this.WaitForCompletionAsync(cancellationToken); + + /// + /// Gets all events that have been collected from the workflow. + /// + public IEnumerable OutgoingEvents => this._eventSink; + + /// + /// Gets the number of events collected since the last access to . + /// + public int NewEventCount => this._eventSink.Count - this._lastBookmark; + + /// + /// Gets all events collected since the last access to . + /// + public IEnumerable NewEvents + { + get + { + if (this._lastBookmark >= this._eventSink.Count) + { + return []; + } + + int currentBookmark = this._lastBookmark; + this._lastBookmark = this._eventSink.Count; + + return this._eventSink.Skip(currentBookmark); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs new file mode 100644 index 0000000000..b458bf98b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs @@ -0,0 +1,619 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ConfigureAwait Usage in Orchestration Code: +// This file uses ConfigureAwait(true) because it runs within orchestration context. +// Durable Task orchestrations require deterministic replay - the same code must execute +// identically across replays. ConfigureAwait(true) ensures continuations run on the +// orchestration's synchronization context, which is essential for replay correctness. +// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay. + +// Superstep execution walkthrough for a workflow like below: +// +// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview) +// │ ▲ +// └──► [D] ──────┘ +// +// Superstep 1 — A runs +// Queues before: A:[input] Results: {} +// Dispatch: A executes, returns resultA +// Route: EdgeMap routes A's output → B's queue +// Queues after: B:[resultA] Results: {A: resultA} +// +// Superstep 2 — B runs +// Queues before: B:[resultA] Results: {A: resultA} +// Dispatch: B executes, returns resultB (type: Order) +// Route: FanOutRouter sends resultB to: +// C's queue (unconditional) +// D's queue (only if resultB.NeedsReview == true) +// Queues after: C:[resultB], D:[resultB] Results: {A: .., B: resultB} +// (D may be empty if condition was false) +// +// Superstep 3 — C and D run in parallel +// Queues before: C:[resultB], D:[resultB] +// Dispatch: C and D execute concurrently via Task.WhenAll +// Route: Both route output → E's queue +// Queues after: E:[resultC, resultD] Results: {.., C: resultC, D: resultD} +// +// Superstep 4 — E runs (fan-in) +// Queues before: E:[resultC, resultD] ◄── IsFanInExecutor("E") = true +// Collect: AggregateQueueMessages merges into JSON array ["resultC","resultD"] +// Dispatch: E executes with aggregated input +// Route: E has no successors → nothing enqueued +// Queues after: (all empty) Results: {.., E: resultE} +// +// Superstep 5 — loop exits (no pending messages) +// GetFinalResult returns resultE + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +// Superstep loop: +// +// ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ +// │ Collect │───►│ Dispatch │───►│ Process Results │ +// │ Executor │ │ Executors │ │ & Route Messages │ +// │ Inputs │ │ in Parallel │ │ │ +// └───────────────┘ └───────────────┘ └───────────────────┘ +// ▲ │ +// └───────────────────────────────────────────┘ +// (repeat until no pending messages) + +/// +/// Runs workflow orchestrations using message-driven superstep execution with Durable Task. +/// +internal sealed class DurableWorkflowRunner +{ + private const int MaxSupersteps = 100; + + /// + /// Initializes a new instance of the class. + /// + /// The durable options containing workflow configurations. + public DurableWorkflowRunner(DurableOptions durableOptions) + { + ArgumentNullException.ThrowIfNull(durableOptions); + + this.Options = durableOptions.Workflows; + } + + /// + /// Gets the workflow options. + /// + private DurableWorkflowOptions Options { get; } + + /// + /// Runs a workflow orchestration. + /// + /// The task orchestration context. + /// The workflow input envelope containing workflow input and metadata. + /// The replay-safe logger for orchestration logging. + /// The result of the workflow execution. + /// Thrown when the specified workflow is not found. + internal async Task RunWorkflowOrchestrationAsync( + TaskOrchestrationContext context, + DurableWorkflowInput workflowInput, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(workflowInput); + + Workflow workflow = this.GetWorkflowOrThrow(context.Name); + + string workflowName = context.Name; + string instanceId = context.InstanceId; + logger.LogWorkflowStarting(workflowName, instanceId); + + WorkflowGraphInfo graphInfo = WorkflowAnalyzer.BuildGraphInfo(workflow); + DurableEdgeMap edgeMap = new(graphInfo); + + // Extract input - the start executor determines the expected input type from its own InputTypes + object input = workflowInput.Input; + + return await RunSuperstepLoopAsync(context, workflow, edgeMap, input, logger).ConfigureAwait(true); + } + + private Workflow GetWorkflowOrThrow(string orchestrationName) + { + string workflowName = WorkflowNamingHelper.ToWorkflowName(orchestrationName); + + if (!this.Options.Workflows.TryGetValue(workflowName, out Workflow? workflow)) + { + throw new InvalidOperationException($"Workflow '{workflowName}' not found."); + } + + return workflow; + } + + /// + /// Runs the workflow execution loop using superstep-based processing. + /// + [UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")] + private static async Task RunSuperstepLoopAsync( + TaskOrchestrationContext context, + Workflow workflow, + DurableEdgeMap edgeMap, + object initialInput, + ILogger logger) + { + SuperstepState state = new(workflow, edgeMap); + + // Convert input to string for the message queue. + // When DurableWorkflowInput is deserialized as DurableWorkflowInput, + // the Input property becomes a JsonElement instead of a string. + // We must extract the raw string value to avoid double-serialization. + string inputString = initialInput switch + { + string s => s, + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? string.Empty, + _ => JsonSerializer.Serialize(initialInput) + }; + + edgeMap.EnqueueInitialInput(inputString, state.MessageQueues); + + bool haltRequested = false; + + for (int superstep = 1; superstep <= MaxSupersteps; superstep++) + { + List executorInputs = CollectExecutorInputs(state, logger); + if (executorInputs.Count == 0) + { + break; + } + + logger.LogSuperstepStarting(superstep, executorInputs.Count); + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId))); + } + + string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true); + + haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger); + + if (haltRequested) + { + break; + } + + // Check if we've reached the limit and still have work remaining + int remainingExecutors = CountRemainingExecutors(state.MessageQueues); + if (superstep == MaxSupersteps && remainingExecutors > 0) + { + logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors); + } + } + + // Publish final events for live streaming (skip during replay) + if (!context.IsReplaying) + { + PublishEventsToLiveStatus(context, state); + } + + string finalResult = GetFinalResult(state.LastResults); + logger.LogWorkflowCompleted(); + + // Return wrapper with both result and events so streaming clients can + // retrieve events from SerializedOutput after the orchestration completes + // (SerializedCustomStatus is cleared by the framework on completion). + // SentMessages carries the final result so parent workflows can route it + // to connected executors, matching the in-process WorkflowHostExecutor behavior. + return new DurableWorkflowResult + { + Result = finalResult, + Events = state.AccumulatedEvents, + SentMessages = !string.IsNullOrEmpty(finalResult) + ? [new TypedPayload { Data = finalResult }] + : [], + HaltRequested = haltRequested + }; + } + + /// + /// Counts the number of executors with pending messages in their queues. + /// + private static int CountRemainingExecutors(Dictionary> messageQueues) + { + return messageQueues.Count(kvp => kvp.Value.Count > 0); + } + + private static async Task DispatchExecutorsInParallelAsync( + TaskOrchestrationContext context, + List executorInputs, + SuperstepState state, + ILogger logger) + { + Task[] dispatchTasks = executorInputs + .Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger)) + .ToArray(); + + return await Task.WhenAll(dispatchTasks).ConfigureAwait(true); + } + + /// + /// Holds state that accumulates and changes across superstep iterations during workflow execution. + /// + /// + /// + /// MessageQueues starts with one entry (the start executor's queue, seeded by + /// ). After each superstep, RouteOutputToSuccessors + /// adds entries for successor executors that receive routed messages. Queues are drained during + /// CollectExecutorInputs; empty queues are skipped. + /// + /// + /// LastResults is updated after every superstep with the result of each executor that ran. + /// At workflow completion, the last non-empty value is returned as the workflow's final result. + /// + /// + private sealed class SuperstepState + { + public SuperstepState(Workflow workflow, DurableEdgeMap edgeMap) + { + this.EdgeMap = edgeMap; + this.ExecutorBindings = workflow.ReflectExecutors(); + } + + public DurableEdgeMap EdgeMap { get; } + + public Dictionary ExecutorBindings { get; } + + public Dictionary> MessageQueues { get; } = []; + + public Dictionary LastResults { get; } = []; + + /// + /// Shared state dictionary across supersteps (scope-prefixed key -> serialized value). + /// + public Dictionary SharedState { get; } = []; + + /// + /// Accumulated workflow events for the durable workflow status (streaming consumption). + /// + public List AccumulatedEvents { get; } = []; + + /// + /// Workflow status published via SetCustomStatus so external clients can poll for streaming events and pending HITL requests. + /// + public DurableWorkflowLiveStatus LiveStatus { get; } = new(); + } + + /// + /// Represents prepared input for an executor ready for dispatch. + /// + private sealed record ExecutorInput(string ExecutorId, DurableMessageEnvelope Envelope, WorkflowExecutorInfo Info); + + /// + /// Collects inputs for all active executors, applying Fan-In aggregation where needed. + /// + private static List CollectExecutorInputs( + SuperstepState state, + ILogger logger) + { + List inputs = []; + + // Only process queues that have pending messages + foreach ((string executorId, Queue queue) in state.MessageQueues + .Where(kvp => kvp.Value.Count > 0)) + { + DurableMessageEnvelope envelope = GetNextEnvelope(executorId, queue, state.EdgeMap, logger); + WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, state.ExecutorBindings); + + inputs.Add(new ExecutorInput(executorId, envelope, executorInfo)); + } + + return inputs; + } + + private static DurableMessageEnvelope GetNextEnvelope( + string executorId, + Queue queue, + DurableEdgeMap edgeMap, + ILogger logger) + { + bool shouldAggregate = edgeMap.IsFanInExecutor(executorId) && queue.Count > 1; + + return shouldAggregate + ? AggregateQueueMessages(queue, executorId, logger) + : queue.Dequeue(); + } + + /// + /// Aggregates all messages in a queue into a JSON array for Fan-In executors. + /// + private static DurableMessageEnvelope AggregateQueueMessages( + Queue queue, + string executorId, + ILogger logger) + { + List messages = []; + List sourceIds = []; + + while (queue.Count > 0) + { + DurableMessageEnvelope envelope = queue.Dequeue(); + messages.Add(envelope.Message); + + if (envelope.SourceExecutorId is not null) + { + sourceIds.Add(envelope.SourceExecutorId); + } + } + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogFanInAggregated(executorId, messages.Count, string.Join(", ", sourceIds)); + } + + return new DurableMessageEnvelope + { + Message = SerializeToJsonArray(messages), + InputTypeName = typeof(string[]).FullName, + SourceExecutorId = sourceIds.Count > 0 ? string.Join(",", sourceIds) : null + }; + } + + /// + /// Processes results from a superstep, updating state and routing messages to successors. + /// + /// true if a halt was requested by any executor; otherwise, false. + private static bool ProcessSuperstepResults( + List inputs, + string[] rawResults, + SuperstepState state, + TaskOrchestrationContext context, + ILogger logger) + { + bool haltRequested = false; + + for (int i = 0; i < inputs.Count; i++) + { + string executorId = inputs[i].ExecutorId; + ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]); + + logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count); + + state.LastResults[executorId] = resultInfo.Result; + + // Merge state updates from activity into shared state + MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes); + + // Accumulate events for the durable workflow status (streaming) + state.AccumulatedEvents.AddRange(resultInfo.Events); + + // Check for halt request + haltRequested |= resultInfo.HaltRequested; + + // Publish events for live streaming (skip during replay) + if (!context.IsReplaying) + { + PublishEventsToLiveStatus(context, state); + } + + RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger); + } + + return haltRequested; + } + + /// + /// Merges state updates from an executor into the shared state. + /// + /// + /// When concurrent executors in the same superstep modify keys in the same scope, + /// last-write-wins semantics apply. + /// + private static void MergeStateUpdates( + SuperstepState state, + Dictionary stateUpdates, + List clearedScopes) + { + Dictionary shared = state.SharedState; + + ApplyClearedScopes(shared, clearedScopes); + + // Apply individual state updates + foreach ((string key, string? value) in stateUpdates) + { + if (value is null) + { + shared.Remove(key); + } + else + { + shared[key] = value; + } + } + } + + /// + /// Removes all keys belonging to the specified scopes from the shared state dictionary. + /// + private static void ApplyClearedScopes(Dictionary shared, List clearedScopes) + { + if (clearedScopes.Count == 0 || shared.Count == 0) + { + return; + } + + List keysToRemove = []; + + foreach (string clearedScope in clearedScopes) + { + string scopePrefix = string.Concat(clearedScope, ":"); + keysToRemove.Clear(); + + foreach (string key in shared.Keys) + { + if (key.StartsWith(scopePrefix, StringComparison.Ordinal)) + { + keysToRemove.Add(key); + } + } + + foreach (string key in keysToRemove) + { + shared.Remove(key); + } + + if (shared.Count == 0) + { + break; + } + } + } + + /// + /// Publishes accumulated workflow events to the durable workflow's custom status, + /// making them available to for live streaming. + /// + /// + /// Custom status is the only orchestration state readable by external clients while + /// the orchestration is still running. It is cleared by the framework on completion, + /// so events are also included in for final retrieval. + /// + private static void PublishEventsToLiveStatus( + TaskOrchestrationContext context, + SuperstepState state) + { + state.LiveStatus.Events = state.AccumulatedEvents; + + // Pass the object directly — the framework's DataConverter handles serialization. + // Pre-serializing would cause double-serialization (string wrapped in JSON quotes). + context.SetCustomStatus(state.LiveStatus); + } + + /// + /// Routes executor output (explicit messages or return value) to successor executors. + /// + private static void RouteOutputToSuccessors( + string executorId, + string result, + List sentMessages, + SuperstepState state, + ILogger logger) + { + if (sentMessages.Count > 0) + { + // Only route messages that have content + foreach (TypedPayload message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Data))) + { + state.EdgeMap.RouteMessage(executorId, message.Data!, message.TypeName, state.MessageQueues, logger); + } + + return; + } + + if (!string.IsNullOrEmpty(result)) + { + state.EdgeMap.RouteMessage(executorId, result, inputTypeName: null, state.MessageQueues, logger); + } + } + + /// + /// Serializes a list of messages into a JSON array. + /// + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing string array.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing string array.")] + private static string SerializeToJsonArray(List messages) + { + return JsonSerializer.Serialize(messages); + } + + /// + /// Creates a for the given executor ID. + /// + /// Thrown when the executor ID is not found in bindings. + private static WorkflowExecutorInfo CreateExecutorInfo( + string executorId, + Dictionary executorBindings) + { + if (!executorBindings.TryGetValue(executorId, out ExecutorBinding? binding)) + { + throw new InvalidOperationException($"Executor '{executorId}' not found in workflow bindings."); + } + + bool isAgentic = WorkflowAnalyzer.IsAgentExecutorType(binding.ExecutorType); + RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null; + Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null; + + return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow); + } + + /// + /// Returns the last non-empty result from executed steps, or empty string if none. + /// + private static string GetFinalResult(Dictionary lastResults) + { + return lastResults.Values.LastOrDefault(value => !string.IsNullOrEmpty(value)) ?? string.Empty; + } + + /// + /// Output from an executor invocation, including its result, + /// messages, state updates, and emitted workflow events. + /// + private sealed record ExecutorResultInfo( + string Result, + List SentMessages, + Dictionary StateUpdates, + List ClearedScopes, + List Events, + bool HaltRequested); + + /// + /// Parses the raw activity result to extract result, messages, events, and state updates. + /// + private static ExecutorResultInfo ParseActivityResult(string rawResult) + { + if (string.IsNullOrEmpty(rawResult)) + { + return new ExecutorResultInfo(rawResult, [], [], [], [], false); + } + + try + { + DurableExecutorOutput? output = JsonSerializer.Deserialize( + rawResult, + DurableWorkflowJsonContext.Default.DurableExecutorOutput); + + if (output is null || !HasMeaningfulContent(output)) + { + return new ExecutorResultInfo(rawResult, [], [], [], [], false); + } + + return new ExecutorResultInfo( + output.Result ?? string.Empty, + output.SentMessages, + output.StateUpdates, + output.ClearedScopes, + output.Events, + output.HaltRequested); + } + catch (JsonException) + { + return new ExecutorResultInfo(rawResult, [], [], [], [], false); + } + } + + /// + /// Determines whether the activity output contains meaningful content. + /// + /// + /// Distinguishes actual activity output from arbitrary JSON that deserialized + /// successfully but with all default/empty values. + /// + private static bool HasMeaningfulContent(DurableExecutorOutput output) + { + return output.Result is not null + || output.SentMessages?.Count > 0 + || output.Events?.Count > 0 + || output.StateUpdates?.Count > 0 + || output.ClearedScopes?.Count > 0 + || output.HaltRequested; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs new file mode 100644 index 0000000000..ed93c5928b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowWaitingForInputEvent.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Event raised when the durable workflow is waiting for external input at a . +/// +/// The serialized input data that was passed to the RequestPort. +/// The request port definition. +[DebuggerDisplay("RequestPort = {RequestPort.Id}")] +public sealed class DurableWorkflowWaitingForInputEvent( + string Input, + RequestPort RequestPort) : WorkflowEvent +{ + /// + /// Gets the serialized input data that was passed to the RequestPort. + /// + public string Input { get; } = Input; + + /// + /// Gets the request port definition. + /// + public RequestPort RequestPort { get; } = RequestPort; + + /// + /// Attempts to deserialize the input data to the specified type. + /// + /// The type to deserialize to. + /// The deserialized input. + /// Thrown when the input cannot be deserialized to the specified type. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")] + public T? GetInputAs() + { + return JsonSerializer.Deserialize(this.Input, DurableSerialization.Options); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs new file mode 100644 index 0000000000..3f78093183 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableDirectEdgeRouter.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Routing decision flow for a single edge. +// Example: the B→D edge from a workflow like below: +// +// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview) +// │ ▲ +// └──► [D] ──────┘ +// +// (condition: x => x.NeedsReview, _sourceOutputType: typeof(Order)) +// +// RouteMessage(envelope) envelope.Message = "{\"NeedsReview\":true, ...}" +// │ +// ▼ +// Has condition? ──── No ────► Enqueue to sink's queue +// │ +// Yes (B→D has one) +// │ +// ▼ +// Deserialize message JSON string → Order object using _sourceOutputType +// │ +// ▼ +// Evaluate _condition(order) order => order.NeedsReview +// │ +// ┌──┴──┐ +// true false +// │ │ +// ▼ └──► Skip (log and return, D will not run) +// Enqueue to +// D's queue + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; + +/// +/// Routes messages from a source executor to a single target executor with optional condition evaluation. +/// +/// +/// +/// Created by during construction — one instance per (source, sink) edge. +/// When an edge has a condition (e.g., order => order.Total > 1000), the router deserialises +/// the serialised JSON message back to the source executor's output type so the condition delegate +/// can evaluate it against strongly-typed properties. If the condition returns false, the +/// message is not forwarded and the target executor will not run for this edge. +/// +/// +/// For sources with multiple successors, individual instances +/// are wrapped in a so a single RouteMessage call +/// fans the same message out to all targets, each evaluating its own condition independently. +/// +/// +internal sealed class DurableDirectEdgeRouter : IDurableEdgeRouter +{ + private readonly string _sourceId; + private readonly string _sinkId; + private readonly Func? _condition; + private readonly Type? _sourceOutputType; + + /// + /// Initializes a new instance of . + /// + /// The source executor ID. + /// The target executor ID. + /// Optional condition function to evaluate before routing. + /// The output type of the source executor for deserialization. + internal DurableDirectEdgeRouter( + string sourceId, + string sinkId, + Func? condition, + Type? sourceOutputType) + { + this._sourceId = sourceId; + this._sinkId = sinkId; + this._condition = condition; + this._sourceOutputType = sourceOutputType; + } + + /// + public void RouteMessage( + DurableMessageEnvelope envelope, + Dictionary> messageQueues, + ILogger logger) + { + if (this._condition is not null) + { + try + { + object? messageObj = DeserializeForCondition(envelope.Message, this._sourceOutputType); + if (!this._condition(messageObj)) + { + logger.LogEdgeConditionFalse(this._sourceId, this._sinkId); + return; + } + } + catch (Exception ex) + { + logger.LogEdgeConditionEvaluationFailed(ex, this._sourceId, this._sinkId); + return; + } + } + + logger.LogEdgeRoutingMessage(this._sourceId, this._sinkId); + EnqueueMessage(messageQueues, this._sinkId, envelope); + } + + /// + /// Deserializes a JSON message to an object for condition evaluation. + /// + /// + /// Messages travel through the durable workflow as serialized JSON strings, but condition + /// delegates need typed objects to evaluate (e.g., order => order.Status == "Approved"). + /// This method converts the JSON back to an object the condition delegate can evaluate. + /// + /// The JSON string representation of the message. + /// + /// The expected type of the message. When provided, enables strongly-typed deserialization + /// so the condition function receives the correct type to evaluate against. + /// + /// + /// The deserialized object, or null if the JSON is empty. + /// + /// Thrown when the JSON is invalid or cannot be deserialized to the target type. + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")] + private static object? DeserializeForCondition(string json, Type? targetType) + { + if (string.IsNullOrEmpty(json)) + { + return null; + } + + // If we know the source executor's output type, deserialize to that specific type + // so the condition function can access strongly-typed properties. + // Otherwise, deserialize as a generic object for basic inspection. + return targetType is null + ? JsonSerializer.Deserialize(json, DurableSerialization.Options) + : JsonSerializer.Deserialize(json, targetType, DurableSerialization.Options); + } + + private static void EnqueueMessage( + Dictionary> queues, + string executorId, + DurableMessageEnvelope envelope) + { + if (!queues.TryGetValue(executorId, out Queue? queue)) + { + queue = new Queue(); + queues[executorId] = queue; + } + + queue.Enqueue(envelope); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs new file mode 100644 index 0000000000..69b8b7cc1c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableEdgeMap.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft. All rights reserved. + +// How WorkflowGraphInfo maps to DurableEdgeMap at runtime. +// For a workflow like below: +// +// [A] ──► [B] ──► [C] ──► [E] +// │ ▲ +// └──► [D] ──────┘ +// (condition: x => x.NeedsReview) +// +// WorkflowGraphInfo DurableEdgeMap +// ┌──────────────────────────┐ ┌──────────────────────────────────────┐ +// │ Successors: │ │ _routersBySource: │ +// │ A → [B] │──constructs──►│ A → [DirectRouter(A→B)] │ +// │ B → [C, D] │ │ B → [FanOutRouter([C, D])] │ +// │ C → [E] │ │ C → [DirectRouter(C→E)] │ +// │ D → [E] │ │ D → [DirectRouter(D→E)] │ +// └──────────────────────────┘ │ │ +// ┌──────────────────────────┐ │ _predecessorCounts: │ +// │ Predecessors: │ │ A → 0 │ +// │ E → [C, D] (fan-in!) │──constructs──►│ B → 1, C → 1, D → 1 │ +// └──────────────────────────┘ │ E → 2 ◄── IsFanInExecutor = true │ +// └──────────────────────────────────────┘ +// +// Usage during superstep execution (continuing the example): +// +// 1. EnqueueInitialInput(msg) ──► MessageQueues["A"].Enqueue(envelope) +// +// 2. After B completes, RouteMessage("B", resultB) ──► _routersBySource["B"] +// │ +// ▼ +// FanOutRouter (B has 2 successors) +// ├─► DirectRouter(B→C) ──► no condition ──► enqueue to C +// └─► DirectRouter(B→D) ──► evaluate x => x.NeedsReview ──► enqueue to D (or skip) +// +// 3. Before superstep 4, IsFanInExecutor("E") returns true (count=2) +// → CollectExecutorInputs aggregates C and D results into ["resultC","resultD"] + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; + +/// +/// Manages message routing through workflow edges for durable orchestrations. +/// +/// +/// +/// This is the durable equivalent of EdgeMap in the in-process runner. +/// It is constructed from (produced by ) +/// and converts the static graph structure into an active routing layer used during superstep execution. +/// +/// +/// What it stores: +/// +/// +/// _routersBySource — For each source executor, a list of instances +/// that know how to deliver messages to successor executors. When a source has multiple successors, a single +/// wraps the individual instances. +/// _predecessorCounts — The number of predecessors for each executor, used to detect +/// fan-in points where multiple incoming messages should be aggregated before execution. +/// _startExecutorId — The entry-point executor that receives the initial workflow input. +/// +/// +/// How it is used during execution: +/// +/// +/// seeds the start executor's queue before the first superstep. +/// After each superstep, DurableWorkflowRunner.RouteOutputToSuccessors calls +/// which looks up the routers for the completed executor and forwards the +/// result to successor queues. Each router may evaluate an edge condition before enqueueing. +/// is checked during input collection to decide whether +/// to aggregate multiple queued messages into a single JSON array before dispatching. +/// +/// +internal sealed class DurableEdgeMap +{ + private readonly Dictionary> _routersBySource = []; + private readonly Dictionary _predecessorCounts = []; + private readonly string _startExecutorId; + + /// + /// Initializes a new instance of from workflow graph info. + /// + /// The workflow graph information containing routing structure. + internal DurableEdgeMap(WorkflowGraphInfo graphInfo) + { + ArgumentNullException.ThrowIfNull(graphInfo); + + this._startExecutorId = graphInfo.StartExecutorId; + + // Build edge routers for each source executor + foreach (KeyValuePair> entry in graphInfo.Successors) + { + string sourceId = entry.Key; + List successorIds = entry.Value; + + if (successorIds.Count == 0) + { + continue; + } + + graphInfo.ExecutorOutputTypes.TryGetValue(sourceId, out Type? sourceOutputType); + + List routers = []; + foreach (string sinkId in successorIds) + { + graphInfo.EdgeConditions.TryGetValue((sourceId, sinkId), out Func? condition); + + routers.Add(new DurableDirectEdgeRouter(sourceId, sinkId, condition, sourceOutputType)); + } + + // If multiple successors, wrap in a fan-out router + if (routers.Count > 1) + { + this._routersBySource[sourceId] = [new DurableFanOutEdgeRouter(sourceId, routers)]; + } + else + { + this._routersBySource[sourceId] = routers; + } + } + + // Store predecessor counts for fan-in detection + foreach (KeyValuePair> entry in graphInfo.Predecessors) + { + this._predecessorCounts[entry.Key] = entry.Value.Count; + } + } + + /// + /// Routes a message from a source executor to its successors. + /// + /// + /// Called by DurableWorkflowRunner.RouteOutputToSuccessors after each superstep. + /// Wraps the message in a and delegates to the + /// appropriate (s) for the source executor. Each router + /// may evaluate an edge condition and, if satisfied, enqueue the envelope into the + /// target executor's message queue for the next superstep. + /// + /// The source executor ID. + /// The serialized message to route. + /// The type name of the message. + /// The message queues to enqueue messages into. + /// The logger for tracing. + internal void RouteMessage( + string sourceId, + string message, + string? inputTypeName, + Dictionary> messageQueues, + ILogger logger) + { + if (!this._routersBySource.TryGetValue(sourceId, out List? routers)) + { + return; + } + + DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName, sourceId); + + foreach (IDurableEdgeRouter router in routers) + { + router.RouteMessage(envelope, messageQueues, logger); + } + } + + /// + /// Enqueues the initial workflow input to the start executor. + /// + /// The serialized initial input message. + /// The message queues to enqueue into. + /// + /// This method is used only at workflow startup to provide input to the first executor. + /// No input type hint is required because the start executor determines its expected input type from its own InputTypes configuration. + /// + internal void EnqueueInitialInput( + string message, + Dictionary> messageQueues) + { + DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName: null); + EnqueueMessage(messageQueues, this._startExecutorId, envelope); + } + + /// + /// Determines if an executor is a fan-in point (has multiple predecessors). + /// + /// The executor ID to check. + /// true if the executor has multiple predecessors; otherwise, false. + internal bool IsFanInExecutor(string executorId) + { + return this._predecessorCounts.TryGetValue(executorId, out int count) && count > 1; + } + + private static void EnqueueMessage( + Dictionary> queues, + string executorId, + DurableMessageEnvelope envelope) + { + if (!queues.TryGetValue(executorId, out Queue? queue)) + { + queue = new Queue(); + queues[executorId] = queue; + } + + queue.Enqueue(envelope); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs new file mode 100644 index 0000000000..f13a0def92 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/DurableFanOutEdgeRouter.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Fan-out routing: one source message is forwarded to multiple targets. +// Example from a workflow like below: +// +// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview) +// │ ▲ +// └──► [D] ──────┘ +// +// B has two successors (C and D), so DurableEdgeMap wraps them: +// +// Executor B completes with resultB (type: Order) +// │ +// ▼ +// FanOutRouter(B) +// ├──► DirectRouter(B→C) ──► no condition ──► enqueue to C +// └──► DirectRouter(B→D) ──► x => x.NeedsReview ──► enqueue to D (or skip) +// +// Each DirectRouter independently evaluates its condition, +// so resultB always reaches C, but only reaches D if NeedsReview is true. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; + +/// +/// Routes messages from a source executor to multiple target executors (fan-out pattern). +/// +/// +/// Created by when a source executor has more than one successor. +/// Wraps the individual instances and delegates +/// to each of them, so the same message is evaluated and +/// potentially enqueued for every target independently. +/// +internal sealed class DurableFanOutEdgeRouter : IDurableEdgeRouter +{ + private readonly string _sourceId; + private readonly List _targetRouters; + + /// + /// Initializes a new instance of . + /// + /// The source executor ID. + /// The routers for each target executor. + internal DurableFanOutEdgeRouter(string sourceId, List targetRouters) + { + this._sourceId = sourceId; + this._targetRouters = targetRouters; + } + + /// + public void RouteMessage( + DurableMessageEnvelope envelope, + Dictionary> messageQueues, + ILogger logger) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Fan-Out from {Source}: routing to {Count} targets", this._sourceId, this._targetRouters.Count); + } + + foreach (IDurableEdgeRouter targetRouter in this._targetRouters) + { + targetRouter.RouteMessage(envelope, messageQueues, logger); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs new file mode 100644 index 0000000000..692ca15b5f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/EdgeRouters/IDurableEdgeRouter.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters; + +/// +/// Defines the contract for routing messages through workflow edges in durable orchestrations. +/// +/// +/// Implementations include for single-target routing +/// and for multi-target fan-out patterns. +/// +internal interface IDurableEdgeRouter +{ + /// + /// Routes a message from the source executor to its target(s). + /// + /// The message envelope containing the message and metadata. + /// The message queues to enqueue messages into. + /// The logger for tracing. + void RouteMessage( + DurableMessageEnvelope envelope, + Dictionary> messageQueues, + ILogger logger); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs new file mode 100644 index 0000000000..f747d497b3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/ExecutorRegistry.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Provides a registry for executor bindings used in durable workflow orchestrations. +/// +/// +/// This registry enables lookup of executors by name, decoupled from specific workflow instances. +/// Executors are registered when workflows are added to . +/// +internal sealed class ExecutorRegistry +{ + private readonly Dictionary _executors = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the number of registered executors. + /// + internal int Count => this._executors.Count; + + /// + /// Attempts to get an executor registration by name. + /// + /// The executor name to look up. + /// When this method returns, contains the registration if found; otherwise, null. + /// if the executor was found; otherwise, . + internal bool TryGetExecutor(string executorName, [NotNullWhen(true)] out ExecutorRegistration? registration) + { + return this._executors.TryGetValue(executorName, out registration); + } + + /// + /// Registers an executor binding from a workflow. + /// + /// The executor name (without GUID suffix). + /// The full executor ID (may include GUID suffix). + /// The workflow containing the executor. + internal void Register(string executorName, string executorId, Workflow workflow) + { + ArgumentException.ThrowIfNullOrEmpty(executorName); + ArgumentException.ThrowIfNullOrEmpty(executorId); + ArgumentNullException.ThrowIfNull(workflow); + + Dictionary bindings = workflow.ReflectExecutors(); + if (!bindings.TryGetValue(executorId, out ExecutorBinding? binding)) + { + throw new InvalidOperationException($"Executor '{executorId}' not found in workflow."); + } + + this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding)); + } +} + +/// +/// Represents a registered executor with its binding information. +/// +/// +/// The may differ from the registered name when the executor +/// ID includes an instance suffix (e.g., "ExecutorName_Guid"). +/// +/// The full executor ID (may include instance suffix). +/// The executor binding containing the factory and configuration. +internal sealed record ExecutorRegistration(string ExecutorId, ExecutorBinding Binding) +{ + /// + /// Creates an instance of the executor. + /// + /// A unique identifier for the run context. + /// The cancellation token. + /// The created executor instance. + internal async ValueTask CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default) + { + if (this.Binding.FactoryAsync is null) + { + throw new InvalidOperationException($"Cannot create executor '{this.ExecutorId}': Binding is a placeholder."); + } + + return await this.Binding.FactoryAsync(runId).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs new file mode 100644 index 0000000000..e25b77f52c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IAwaitableWorkflowRun.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a workflow run that can be awaited for completion. +/// +/// +/// +/// This interface extends to provide methods for waiting +/// until the workflow execution completes. Not all workflow runners support this capability. +/// +/// +/// Use pattern matching to check if a workflow run supports awaiting: +/// +/// IWorkflowRun run = await client.RunAsync(workflow, input); +/// if (run is IAwaitableWorkflowRun awaitableRun) +/// { +/// string? result = await awaitableRun.WaitForCompletionAsync<string>(); +/// } +/// +/// +/// +public interface IAwaitableWorkflowRun : IWorkflowRun +{ + /// + /// Waits for the workflow to complete and returns the result. + /// + /// The expected result type. + /// A cancellation token to observe. + /// The result of the workflow execution. + /// Thrown when the workflow failed or was terminated. + ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs new file mode 100644 index 0000000000..079ee7258e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IStreamingWorkflowRun.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a workflow run that supports streaming workflow events as they occur. +/// +/// +/// This interface defines the contract for streaming workflow runs in durable execution +/// environments. Implementations provide real-time access to workflow events. +/// +public interface IStreamingWorkflowRun +{ + /// + /// Gets the unique identifier for the run. + /// + /// + /// This identifier can be provided at the start of the run, or auto-generated. + /// For durable runs, this corresponds to the orchestration instance ID. + /// + string RunId { get; } + + /// + /// Asynchronously streams workflow events as they occur during workflow execution. + /// + /// + /// This method yields instances in real time as the workflow + /// progresses. The stream completes when the workflow completes, fails, or is terminated. + /// Events are delivered in the order they are raised. + /// + /// + /// A that can be used to cancel the streaming operation. + /// If cancellation is requested, the stream will end and no further events will be yielded. + /// + /// + /// An asynchronous stream of objects representing significant + /// workflow state changes. + /// + IAsyncEnumerable WatchStreamAsync(CancellationToken cancellationToken = default); + + /// + /// Sends a response to a to resume the workflow. + /// + /// The type of the response data. + /// The request event to respond to. + /// The response data to send. + /// A cancellation token to observe. + /// A representing the asynchronous operation. + ValueTask SendResponseAsync( + DurableWorkflowWaitingForInputEvent requestEvent, + TResponse response, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs new file mode 100644 index 0000000000..e84f3fe4cd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Defines a client for running and managing workflow executions. +/// +public interface IWorkflowClient +{ + /// + /// Runs a workflow and returns a handle to monitor its execution. + /// + /// The type of the input to the workflow. + /// The workflow to execute. + /// The input to pass to the workflow's starting executor. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to monitor the workflow execution. + ValueTask RunAsync( + Workflow workflow, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull; + + /// + /// Runs a workflow with string input and returns a handle to monitor its execution. + /// + /// The workflow to execute. + /// The string input to pass to the workflow. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to monitor the workflow execution. + ValueTask RunAsync( + Workflow workflow, + string input, + string? runId = null, + CancellationToken cancellationToken = default); + + /// + /// Starts a workflow and returns a streaming handle to watch events in real-time. + /// + /// The type of the input to the workflow. + /// The workflow to execute. + /// The input to pass to the workflow's starting executor. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to stream workflow events. + ValueTask StreamAsync( + Workflow workflow, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull; + + /// + /// Starts a workflow with string input and returns a streaming handle to watch events in real-time. + /// + /// The workflow to execute. + /// The string input to pass to the workflow. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to stream workflow events. + ValueTask StreamAsync( + Workflow workflow, + string input, + string? runId = null, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs new file mode 100644 index 0000000000..f6d5e5b203 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowRun.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a running instance of a workflow. +/// +public interface IWorkflowRun +{ + /// + /// Gets the unique identifier for the run. + /// + /// + /// This identifier can be provided at the start of the run, or auto-generated. + /// For durable runs, this corresponds to the orchestration instance ID. + /// + string RunId { get; } + + /// + /// Gets all events that have been emitted by the workflow. + /// + IEnumerable OutgoingEvents { get; } + + /// + /// Gets the number of events emitted since the last access to . + /// + int NewEventCount { get; } + + /// + /// Gets all events emitted by the workflow since the last access to this property. + /// + /// + /// Each access to this property advances the bookmark, so subsequent accesses + /// will only return events emitted after the previous access. + /// + IEnumerable NewEvents { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs new file mode 100644 index 0000000000..c60f00d5f6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/PendingRequestPortStatus.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents a RequestPort the workflow is paused at, waiting for a response. +/// +/// The RequestPort ID identifying which input is needed. +/// The serialized request data passed to the RequestPort. +internal sealed record PendingRequestPortStatus( + string EventName, + string Input); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs new file mode 100644 index 0000000000..7c0998585a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/TypedPayload.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Pairs a JSON-serialized payload with its assembly-qualified type name +/// for type-safe deserialization across activity boundaries. +/// +internal sealed class TypedPayload +{ + /// + /// Gets or sets the assembly-qualified type name of the payload. + /// + public string? TypeName { get; set; } + + /// + /// Gets or sets the serialized payload data as JSON. + /// + public string? Data { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs new file mode 100644 index 0000000000..bb4d295616 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowAnalyzer.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Analyzes workflow structure to extract executor metadata and build graph information +/// for message-driven execution. +/// +internal static class WorkflowAnalyzer +{ + private const string AgentExecutorTypeName = "AIAgentHostExecutor"; + private const string AgentAssemblyPrefix = "Microsoft.Agents.AI"; + private const string ExecutorTypePrefix = "Executor"; + + /// + /// Analyzes a workflow instance and returns a list of executors with their metadata. + /// + /// The workflow instance to analyze. + /// A list of executor information in workflow order. + internal static List GetExecutorsFromWorkflowInOrder(Workflow workflow) + { + ArgumentNullException.ThrowIfNull(workflow); + + return workflow.ReflectExecutors() + .Select(kvp => CreateExecutorInfo(kvp.Key, kvp.Value)) + .ToList(); + } + + /// + /// Builds the workflow graph information needed for message-driven execution. + /// + /// + /// + /// Extracts routing information including successors, predecessors, edge conditions, + /// and output types. Supports cyclic workflows through message-driven superstep execution. + /// + /// + /// The returned is consumed by DurableEdgeMap + /// to build the runtime routing layer: + /// Successors become IDurableEdgeRouter instances, + /// Predecessors become fan-in counts, and + /// EdgeConditions / ExecutorOutputTypes are passed into + /// DurableDirectEdgeRouter for conditional routing with typed deserialization. + /// + /// + /// The workflow instance to analyze. + /// A graph info object containing routing information. + internal static WorkflowGraphInfo BuildGraphInfo(Workflow workflow) + { + ArgumentNullException.ThrowIfNull(workflow); + + Dictionary executors = workflow.ReflectExecutors(); + + WorkflowGraphInfo graphInfo = new() + { + StartExecutorId = workflow.StartExecutorId + }; + + InitializeExecutorMappings(graphInfo, executors); + PopulateGraphFromEdges(graphInfo, workflow.Edges); + + return graphInfo; + } + + /// + /// Determines whether the specified executor type is an agentic executor. + /// + /// The executor type to check. + /// true if the executor is an agentic executor; otherwise, false. + internal static bool IsAgentExecutorType(Type executorType) + { + string typeName = executorType.FullName ?? executorType.Name; + string assemblyName = executorType.Assembly.GetName().Name ?? string.Empty; + + return typeName.Contains(AgentExecutorTypeName, StringComparison.OrdinalIgnoreCase) + && assemblyName.Contains(AgentAssemblyPrefix, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Creates a from an executor binding. + /// + /// The unique identifier of the executor. + /// The executor binding containing type and configuration information. + /// A new instance with extracted metadata. + private static WorkflowExecutorInfo CreateExecutorInfo(string executorId, ExecutorBinding binding) + { + bool isAgentic = IsAgentExecutorType(binding.ExecutorType); + RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null; + Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null; + + return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow); + } + + /// + /// Initializes the graph info with empty collections for each executor. + /// + /// The graph info to initialize. + /// The dictionary of executor bindings. + private static void InitializeExecutorMappings(WorkflowGraphInfo graphInfo, Dictionary executors) + { + foreach ((string executorId, ExecutorBinding binding) in executors) + { + graphInfo.Successors[executorId] = []; + graphInfo.Predecessors[executorId] = []; + graphInfo.ExecutorOutputTypes[executorId] = GetExecutorOutputType(binding.ExecutorType); + } + } + + /// + /// Populates the graph info with successor/predecessor relationships and edge conditions. + /// + /// The graph info to populate. + /// The dictionary of edges grouped by source executor ID. + private static void PopulateGraphFromEdges(WorkflowGraphInfo graphInfo, Dictionary> edges) + { + foreach ((string sourceId, HashSet edgeSet) in edges) + { + List successors = graphInfo.Successors[sourceId]; + + foreach (Edge edge in edgeSet) + { + AddSuccessorsFromEdge(graphInfo, sourceId, edge, successors); + TryAddEdgeCondition(graphInfo, edge); + } + } + } + + /// + /// Adds successor relationships from an edge to the graph info. + /// + /// The graph info to update. + /// The source executor ID. + /// The edge containing connection information. + /// The list of successors to append to. + private static void AddSuccessorsFromEdge( + WorkflowGraphInfo graphInfo, + string sourceId, + Edge edge, + List successors) + { + foreach (string sinkId in edge.Data.Connection.SinkIds) + { + if (!graphInfo.Successors.ContainsKey(sinkId)) + { + continue; + } + + successors.Add(sinkId); + graphInfo.Predecessors[sinkId].Add(sourceId); + } + } + + /// + /// Extracts and adds an edge condition to the graph info if present. + /// + /// The graph info to update. + /// The edge that may contain a condition. + private static void TryAddEdgeCondition(WorkflowGraphInfo graphInfo, Edge edge) + { + DirectEdgeData? directEdge = edge.DirectEdgeData; + + if (directEdge?.Condition is not null) + { + graphInfo.EdgeConditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition; + } + } + + /// + /// Extracts the output type from an executor type by walking the inheritance chain. + /// + /// The executor type to analyze. + /// + /// The TOutput type for Executor<TInput, TOutput>, + /// or null for Executor<TInput> (void output) or non-executor types. + /// + private static Type? GetExecutorOutputType(Type executorType) + { + Type? currentType = executorType; + + while (currentType is not null) + { + Type? outputType = TryExtractOutputTypeFromGeneric(currentType); + if (outputType is not null || IsVoidExecutorType(currentType)) + { + return outputType; + } + + currentType = currentType.BaseType; + } + + return null; + } + + /// + /// Attempts to extract the output type from a generic executor type. + /// + /// The type to inspect. + /// The TOutput type if this is an Executor<TInput, TOutput>; otherwise, null. + private static Type? TryExtractOutputTypeFromGeneric(Type type) + { + if (!type.IsGenericType) + { + return null; + } + + Type genericDefinition = type.GetGenericTypeDefinition(); + Type[] genericArgs = type.GetGenericArguments(); + + bool isExecutorType = genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal); + if (!isExecutorType) + { + return null; + } + + // Executor - return TOutput + if (genericArgs.Length == 2) + { + return genericArgs[1]; + } + + return null; + } + + /// + /// Determines whether the type is a void-returning executor (Executor<TInput>). + /// + /// The type to check. + /// true if this is an Executor with a single type parameter; otherwise, false. + private static bool IsVoidExecutorType(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + Type genericDefinition = type.GetGenericTypeDefinition(); + Type[] genericArgs = type.GetGenericArguments(); + + // Executor with 1 type parameter indicates void return + return genericArgs.Length == 1 + && genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs new file mode 100644 index 0000000000..ffaa9fbe1f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowExecutorInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents an executor in the workflow with its metadata. +/// +/// The unique identifier of the executor. +/// Indicates whether this executor is an agentic executor. +/// The request port if this executor is a request port executor; otherwise, null. +/// The sub-workflow if this executor is a sub-workflow executor; otherwise, null. +internal sealed record WorkflowExecutorInfo( + string ExecutorId, + bool IsAgenticExecutor, + RequestPort? RequestPort = null, + Workflow? SubWorkflow = null) +{ + /// + /// Gets a value indicating whether this executor is a request port executor (human-in-the-loop). + /// + public bool IsRequestPortExecutor => this.RequestPort is not null; + + /// + /// Gets a value indicating whether this executor is a sub-workflow executor. + /// + public bool IsSubworkflowExecutor => this.SubWorkflow is not null; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs new file mode 100644 index 0000000000..a504a07b13 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowGraphInfo.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Example: Given this workflow graph with a fan-out from B and a fan-in at E, +// plus a conditional edge from B to D: +// +// [A] ──► [B] ──► [C] ──► [E] +// │ ▲ +// └──► [D] ──────┘ +// (condition: +// x => x.NeedsReview) +// +// WorkflowAnalyzer.BuildGraphInfo() produces: +// +// StartExecutorId = "A" +// +// Successors (who does each executor send output to?): +// ┌──────────┬──────────────┐ +// │ "A" │ ["B"] │ +// │ "B" │ ["C", "D"] │ ◄── fan-out: B sends to both C and D +// │ "C" │ ["E"] │ +// │ "D" │ ["E"] │ +// │ "E" │ [] │ ◄── terminal: no successors +// └──────────┴──────────────┘ +// +// Predecessors (who feeds into each executor?): +// ┌──────────┬──────────────┐ +// │ "A" │ [] │ ◄── start: no predecessors +// │ "B" │ ["A"] │ +// │ "C" │ ["B"] │ +// │ "D" │ ["B"] │ +// │ "E" │ ["C", "D"] │ ◄── fan-in: count=2, messages will be aggregated +// └──────────┴──────────────┘ +// +// EdgeConditions (which edges have routing conditions?): +// ┌──────────────────┬──────────────────────────┐ +// │ ("B", "D") │ x => x.NeedsReview │ ◄── D only receives if condition is true +// └──────────────────┴──────────────────────────┘ +// (The B→C edge has no condition, so C always receives B's output.) +// +// ExecutorOutputTypes (what type does each executor return?): +// ┌──────────┬──────────────────┐ +// │ "A" │ typeof(string) │ ◄── used by DurableDirectEdgeRouter to deserialize +// │ "B" │ typeof(Order) │ the JSON message for condition evaluation +// │ "C" │ typeof(Report) │ +// │ "D" │ typeof(Report) │ +// │ "E" │ typeof(string) │ +// └──────────┴──────────────────┘ +// +// DurableEdgeMap then consumes this to build the runtime routing layer. + +using System.Diagnostics; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Represents the workflow graph structure needed for message-driven execution. +/// +/// +/// +/// This is a simplified representation that contains only the information needed +/// for routing messages between executors during superstep execution: +/// +/// +/// Successors for routing messages forward +/// Predecessors for detecting fan-in points +/// Edge conditions for conditional routing +/// Output types for deserialization during condition evaluation +/// +/// +[DebuggerDisplay("Start = {StartExecutorId}, Executors = {Successors.Count}")] +internal sealed class WorkflowGraphInfo +{ + /// + /// Gets or sets the starting executor ID for the workflow. + /// + public string StartExecutorId { get; set; } = string.Empty; + + /// + /// Maps each executor ID to its successors (for message routing). + /// + public Dictionary> Successors { get; } = []; + + /// + /// Maps each executor ID to its predecessors (for fan-in detection). + /// + public Dictionary> Predecessors { get; } = []; + + /// + /// Maps edge connections (sourceId, targetId) to their condition functions. + /// The condition function takes the predecessor's result and returns true if the edge should be followed. + /// + public Dictionary<(string SourceId, string TargetId), Func?> EdgeConditions { get; } = []; + + /// + /// Maps executor IDs to their output types (for proper deserialization during condition evaluation). + /// + public Dictionary ExecutorOutputTypes { get; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs new file mode 100644 index 0000000000..0b657b3235 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/WorkflowNamingHelper.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Provides helper methods for workflow naming conventions used in durable orchestrations. +/// +internal static class WorkflowNamingHelper +{ + internal const string OrchestrationFunctionPrefix = "dafx-"; + private const char ExecutorIdSuffixSeparator = '_'; + + /// + /// Converts a workflow name to its corresponding orchestration function name. + /// + /// The workflow name. + /// The orchestration function name. + /// Thrown when the workflow name is null or empty. + internal static string ToOrchestrationFunctionName(string workflowName) + { + ArgumentException.ThrowIfNullOrEmpty(workflowName); + return string.Concat(OrchestrationFunctionPrefix, workflowName); + } + + /// + /// Converts an orchestration function name back to its workflow name. + /// + /// The orchestration function name. + /// The workflow name. + /// Thrown when the orchestration function name is null, empty, or doesn't have the expected prefix. + internal static string ToWorkflowName(string orchestrationFunctionName) + { + ArgumentException.ThrowIfNullOrEmpty(orchestrationFunctionName); + + if (!TryGetWorkflowName(orchestrationFunctionName, out string? workflowName)) + { + throw new ArgumentException( + $"Orchestration function name '{orchestrationFunctionName}' does not have the expected '{OrchestrationFunctionPrefix}' prefix or is missing a workflow name.", + nameof(orchestrationFunctionName)); + } + + return workflowName; + } + + /// + /// Extracts the executor name from an executor ID. + /// + /// + /// + /// For non-agentic executors, the executor ID is the same as the executor name (e.g., "OrderParser"). + /// + /// + /// For agentic executors, the workflow builder appends a GUID suffix separated by an underscore + /// (e.g., "Physicist_8884e71021334ce49517fa2b17b1695b"). This method extracts just the name portion. + /// + /// + /// The executor ID, which may contain a GUID suffix. + /// The executor name without any GUID suffix. + /// Thrown when the executor ID is null or empty. + internal static string GetExecutorName(string executorId) + { + ArgumentException.ThrowIfNullOrEmpty(executorId); + + int separatorIndex = executorId.LastIndexOf(ExecutorIdSuffixSeparator); + if (separatorIndex > 0) + { + ReadOnlySpan suffix = executorId.AsSpan(separatorIndex + 1); + if (IsGuidSuffix(suffix)) + { + return executorId[..separatorIndex]; + } + } + + return executorId; + } + + /// + /// Checks whether the given span looks like a sanitized GUID (32 hex characters). + /// + private static bool IsGuidSuffix(ReadOnlySpan value) + { + if (value.Length != 32) + { + return false; + } + + foreach (char c in value) + { + if (!char.IsAsciiHexDigit(c)) + { + return false; + } + } + + return true; + } + + private static bool TryGetWorkflowName(string? orchestrationFunctionName, [NotNullWhen(true)] out string? workflowName) + { + workflowName = null; + + if (string.IsNullOrEmpty(orchestrationFunctionName) || + !orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal)) + { + return false; + } + + workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..]; + return workflowName.Length > 0; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index fa0b9ef287..8239ff17cc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -21,6 +21,15 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor { ArgumentNullException.ThrowIfNull(context); + // Orchestration triggers use a different input binding mechanism than other triggers. + // The encoded orchestrator state is retrieved via BindInputAsync on the orchestration trigger binding, + // not through IFunctionInputBindingFeature. Handle this case first to avoid unnecessary binding work. + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint) + { + await ExecuteOrchestrationAsync(context); + return; + } + // Acquire the input binding feature (fail fast if missing rather than null-forgiving operator). IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get() ?? throw new InvalidOperationException("Function input binding feature is not available on the current context."); @@ -57,11 +66,67 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor if (durableTaskClient is null) { - // This is not expected to happen since all built-in functions are - // expected to have a Durable Task client binding. + // This is not expected to happen since all built-in functions (other than orchestration triggers) + // are expected to have a Durable Task client binding. throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}."); } + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint) + { + if (httpRequestData == null) + { + throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrchestrationHttpTriggerAsync( + httpRequestData, + durableTaskClient, + context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint) + { + if (httpRequestData == null) + { + throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.GetWorkflowStatusAsync( + httpRequestData, + durableTaskClient, + context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint) + { + if (httpRequestData == null) + { + throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.RespondToWorkflowAsync( + httpRequestData, + durableTaskClient, + context); + return; + } + + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint) + { + if (encodedEntityRequest is null) + { + throw new InvalidOperationException($"Activity trigger input binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync( + encodedEntityRequest, + durableTaskClient, + context); + return; + } + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint) { if (httpRequestData == null) @@ -70,9 +135,9 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor } context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync( - httpRequestData, - durableTaskClient, - context); + httpRequestData, + durableTaskClient, + context); return; } @@ -104,4 +169,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}."); } + + private static async ValueTask ExecuteOrchestrationAsync(FunctionContext context) + { + BindingMetadata? orchestrationBinding = null; + foreach (BindingMetadata binding in context.FunctionDefinition.InputBindings.Values) + { + if (string.Equals(binding.Type, "orchestrationTrigger", StringComparison.OrdinalIgnoreCase)) + { + orchestrationBinding = binding; + break; + } + } + + if (orchestrationBinding is null) + { + throw new InvalidOperationException($"Orchestration trigger binding is missing for the invocation {context.InvocationId}."); + } + + InputBindingData triggerInputData = await context.BindInputAsync(orchestrationBinding); + if (triggerInputData?.Value is not string encodedOrchestratorState) + { + throw new InvalidOperationException($"Orchestration history state was either missing from the input or not a string value for invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = BuiltInFunctions.RunWorkflowOrchestration( + encodedOrchestratorState, + context); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 8573a80613..6dc1ab2244 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using System.Net; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Extensions.Mcp; using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.Extensions.AI; @@ -21,6 +24,203 @@ internal static class BuiltInFunctions internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}"; internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}"; internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}"; + internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}"; + internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}"; + internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}"; + internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}"; + internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}"; + +#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing + internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); +#pragma warning restore IL3000 + + /// + /// Starts a workflow orchestration in response to an HTTP request. + /// The workflow name is derived from the function name by stripping the . + /// Callers can optionally provide a custom run ID via the runId query string parameter + /// (e.g., /api/workflows/MyWorkflow/run?runId=my-id). If not provided, one is auto-generated. + /// + public static async Task RunWorkflowOrchestrationHttpTriggerAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + string workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty); + string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName); + string? inputMessage = await req.ReadAsStringAsync(); + + if (string.IsNullOrEmpty(inputMessage)) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty."); + } + + DurableWorkflowInput orchestrationInput = new() { Input = inputMessage }; + + // Allow users to provide a custom run ID via query string; otherwise, auto-generate one. + string? instanceId = req.Query["runId"]; + StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null; + string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); + await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}"); + return response; + } + + /// + /// Returns the workflow status including any pending HITL requests. + /// The run ID is extracted from the route parameter {runId}. + /// + public static async Task GetWorkflowStatusAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; + if (string.IsNullOrEmpty(runId)) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); + } + + OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); + if (metadata is null) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); + } + + // Parse HITL inputs the workflow is waiting for from the durable workflow status + List? waitingForInput = null; + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus) + && liveStatus.PendingEvents.Count > 0) + { + waitingForInput = liveStatus.PendingEvents; + } + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + runId, + status = metadata.RuntimeStatus.ToString(), + waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement }) + }); + return response; + } + + /// + /// Sends a response to a pending RequestPort, resuming the workflow. + /// Expects a JSON body: { "eventName": "...", "response": { ... } }. + /// + public static async Task RespondToWorkflowAsync( + [HttpTrigger] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext context) + { + string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; + if (string.IsNullOrEmpty(runId)) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); + } + + WorkflowRespondRequest? request; + try + { + request = await req.ReadFromJsonAsync(context.CancellationToken); + } + catch (JsonException) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON."); + } + + if (request is null || string.IsNullOrEmpty(request.EventName) + || request.Response.ValueKind == JsonValueKind.Undefined) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property."); + } + + // Verify the orchestration exists and is in a valid state + OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); + if (metadata is null) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); + } + + if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed + or OrchestrationRuntimeStatus.Failed + or OrchestrationRuntimeStatus.Terminated) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, + $"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'."); + } + + // Verify the workflow is waiting for the specified event. + // If status can't be parsed (e.g., not yet set during early execution), allow the event through — + // Durable Task safely queues it until the orchestration reaches WaitForExternalEvent. + bool eventValidated = false; + if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)) + { + if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, + $"Workflow is not waiting for event '{request.EventName}'."); + } + + eventValidated = true; + } + + // Raise the external event to unblock the orchestration's WaitForExternalEvent call + await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText()); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); + await response.WriteAsJsonAsync(new + { + message = eventValidated + ? "Response sent to workflow." + : "Response sent to workflow. Event could not be validated against pending requests.", + runId, + eventName = request.EventName, + validated = eventValidated, + }); + return response; + } + + /// + /// Executes a workflow activity by looking up the registered executor and delegating to it. + /// The executor name is derived from the activity function name via . + /// + public static Task InvokeWorkflowActivityAsync( + [ActivityTrigger] string input, + [DurableClient] DurableTaskClient durableTaskClient, + FunctionContext functionContext) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(durableTaskClient); + ArgumentNullException.ThrowIfNull(functionContext); + + string activityFunctionName = functionContext.FunctionDefinition.Name; + string executorName = WorkflowNamingHelper.ToWorkflowName(activityFunctionName); + + DurableOptions durableOptions = functionContext.InstanceServices.GetRequiredService(); + if (!durableOptions.Workflows.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration)) + { + throw new InvalidOperationException($"Executor '{executorName}' not found in workflow options."); + } + + return DurableActivityExecutor.ExecuteAsync(registration.Binding, input, functionContext.CancellationToken); + } + + /// + /// Runs a workflow orchestration by delegating to + /// via . + /// + public static string RunWorkflowOrchestration( + string encodedOrchestratorRequest, + FunctionContext functionContext) + { + ArgumentNullException.ThrowIfNull(encodedOrchestratorRequest); + ArgumentNullException.ThrowIfNull(functionContext); + + WorkflowOrchestrator orchestrator = new(functionContext.InstanceServices); + return GrpcOrchestrationRunner.LoadAndRun(encodedOrchestratorRequest, orchestrator, functionContext.InstanceServices); + } // Exposed as an entity trigger via AgentFunctionsProvider public static Task InvokeAgentAsync( @@ -332,6 +532,15 @@ internal static class BuiltInFunctions [property: JsonPropertyName("status")] int Status, [property: JsonPropertyName("thread_id")] string ThreadId); + /// + /// Represents a request to respond to a pending RequestPort in a workflow. + /// + /// The name of the event to raise (the RequestPort ID). + /// The response payload to send to the workflow. + private sealed record WorkflowRespondRequest( + [property: JsonPropertyName("eventName")] string? EventName, + [property: JsonPropertyName("response")] JsonElement Response); + /// /// A service provider that combines the original service provider with an additional DurableTaskClient instance. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index a606629dc2..93c90bba9c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -1,6 +1,10 @@ # Release History -## +## [Unreleased] + +- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) + +## v1.0.0-preview.251219.1 - Addressed incompatibility issue with `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` >= 1.11.0 ([#2759](https://github.com/microsoft/agent-framework/pull/2759)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs index f626db2a90..65578a7383 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Agents.AI.DurableTask; using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; using Microsoft.Extensions.Logging; @@ -17,10 +16,6 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat private readonly IServiceProvider _serviceProvider; private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider; -#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing - private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); -#pragma warning restore IL3000 - public DurableAgentFunctionMetadataTransformer( IReadOnlyDictionary> agents, ILogger logger, @@ -45,14 +40,14 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); - original.Add(CreateAgentTrigger(agentName)); + original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName)); if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) { if (agentTriggerOptions.HttpTrigger.IsEnabled) { this._logger.LogRegisteringTriggerForAgent(agentName, "http"); - original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run")); + original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint)); } if (agentTriggerOptions.McpToolTrigger.IsEnabled) @@ -65,39 +60,6 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat } } - private static DefaultFunctionMetadata CreateAgentTrigger(string name) - { - return new DefaultFunctionMetadata() - { - Name = AgentSessionId.ToEntityName(name), - Language = "dotnet-isolated", - RawBindings = - [ - """{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""", - """{"name":"client","type":"durableClient","direction":"In"}""" - ], - EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint, - ScriptFile = s_builtInFunctionsScriptFile, - }; - } - - private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route) - { - return new DefaultFunctionMetadata() - { - Name = $"{BuiltInFunctions.HttpPrefix}{name}", - Language = "dotnet-isolated", - RawBindings = - [ - $"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}", - "{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}", - "{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}" - ], - EntryPoint = BuiltInFunctions.RunAgentHttpFunctionEntryPoint, - ScriptFile = s_builtInFunctionsScriptFile, - }; - } - private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description) { return new DefaultFunctionMetadata @@ -112,7 +74,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat """{"name":"client","type":"durableClient","direction":"In"}""" ], EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, - ScriptFile = s_builtInFunctionsScriptFile, + ScriptFile = BuiltInFunctions.ScriptFile, }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs new file mode 100644 index 0000000000..d88cd939d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides factory methods for creating common instances +/// used by function metadata transformers. +/// +internal static class FunctionMetadataFactory +{ + /// + /// Creates function metadata for an entity trigger function. + /// + /// The base name used to derive the entity function name. + /// A configured for an entity trigger. + internal static DefaultFunctionMetadata CreateEntityTrigger(string name) + { + return new DefaultFunctionMetadata() + { + Name = AgentSessionId.ToEntityName(name), + Language = "dotnet-isolated", + RawBindings = + [ + """{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""", + """{"name":"client","type":"durableClient","direction":"In"}""" + ], + EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint, + ScriptFile = BuiltInFunctions.ScriptFile, + }; + } + + /// + /// Creates function metadata for an HTTP trigger function. + /// + /// The base name used to derive the HTTP function name. + /// The HTTP route for the trigger. + /// The entry point method for the HTTP trigger. + /// The allowed HTTP methods as a JSON array fragment (e.g., "\"get\""). Defaults to POST. + /// A configured for an HTTP trigger. + internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint, string methods = "\"post\"") + { + return new DefaultFunctionMetadata() + { + Name = $"{BuiltInFunctions.HttpPrefix}{name}", + Language = "dotnet-isolated", + RawBindings = + [ + $"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [{methods}],\"route\":\"{route}\"}}", + "{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}", + "{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}" + ], + EntryPoint = entryPoint, + ScriptFile = BuiltInFunctions.ScriptFile, + }; + } + + /// + /// Creates function metadata for an activity trigger function. + /// + /// The name of the activity function. + /// A configured for an activity trigger. + internal static DefaultFunctionMetadata CreateActivityTrigger(string functionName) + { + return new DefaultFunctionMetadata() + { + Name = functionName, + Language = "dotnet-isolated", + RawBindings = + [ + """{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""", + """{"name":"durableTaskClient","type":"durableClient","direction":"In"}""" + ], + EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, + ScriptFile = BuiltInFunctions.ScriptFile, + }; + } + + /// + /// Creates function metadata for an orchestration trigger function. + /// + /// The name of the orchestration function. + /// The entry point method for the orchestration trigger. + /// A configured for an orchestration trigger. + internal static DefaultFunctionMetadata CreateOrchestrationTrigger(string functionName, string entryPoint) + { + return new DefaultFunctionMetadata() + { + Name = functionName, + Language = "dotnet-isolated", + RawBindings = + [ + """{"name":"context","type":"orchestrationTrigger","direction":"In"}""" + ], + EntryPoint = entryPoint, + ScriptFile = BuiltInFunctions.ScriptFile, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index e13c6008ea..ceb47c389a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; using Microsoft.Extensions.DependencyInjection; @@ -43,4 +44,90 @@ public static class FunctionsApplicationBuilderExtensions return builder; } + + /// + /// Configures durable options for the functions application, allowing customization of Durable Task framework + /// settings. + /// + /// This method ensures that a single shared instance is used across all + /// configuration calls. If any workflows have been added, it configures the necessary orchestrations and registers + /// required middleware. + /// The functions application builder to configure. Cannot be null. + /// An action that configures the instance. Cannot be null. + /// The updated instance, enabling method chaining. + public static FunctionsApplicationBuilder ConfigureDurableOptions( + this FunctionsApplicationBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + // Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions + FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); + + builder.Services.ConfigureDurableOptions(configure); + + if (sharedOptions.Workflows.Workflows.Count > 0) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + + EnsureMiddlewareRegistered(builder); + + return builder; + } + + /// + /// Configures durable workflow support for the specified Azure Functions application builder. + /// + /// The instance to configure for durable workflows. + /// An action that configures the , allowing customization of durable workflow behavior. + /// The updated instance, enabling method chaining. + public static FunctionsApplicationBuilder ConfigureDurableWorkflows( + this FunctionsApplicationBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + return builder.ConfigureDurableOptions(options => configure(options.Workflows)); + } + + private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder) + { + // Guard against registering the middleware filter multiple times in the pipeline. + if (builder.Services.Any(d => d.ServiceType == typeof(BuiltInFunctionExecutor))) + { + return; + } + + builder.UseWhen(static context => + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) + ); + builder.Services.TryAddSingleton(); + } + + /// + /// Gets or creates a shared instance from the service collection. + /// + private static FunctionsDurableOptions GetOrCreateSharedOptions(IServiceCollection services) + { + ServiceDescriptor? existingDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null); + + if (existingDescriptor?.ImplementationInstance is FunctionsDurableOptions existing) + { + return existing; + } + + FunctionsDurableOptions options = new(); + services.AddSingleton(options); + services.AddSingleton(options); + return options; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs new file mode 100644 index 0000000000..6e7b6ec5a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Provides Azure Functions–specific configuration for durable workflows. +/// +internal sealed class FunctionsDurableOptions : DurableOptions +{ + private readonly HashSet _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Enables the status HTTP endpoint for the specified workflow. + /// + internal void EnableStatusEndpoint(string workflowName) + { + this._statusEndpointWorkflows.Add(workflowName); + } + + /// + /// Returns whether the status endpoint is enabled for the specified workflow. + /// + internal bool IsStatusEndpointEnabled(string workflowName) + { + return this._statusEndpointWorkflows.Contains(workflowName); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs index c49d2b39df..73c3140266 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Logs.cs @@ -17,4 +17,16 @@ internal static partial class Logs Level = LogLevel.Information, Message = "Registering {TriggerType} function for agent '{AgentName}'")] public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType); + + [LoggerMessage( + EventId = 102, + Level = LogLevel.Information, + Message = "Registering {TriggerType} trigger function '{FunctionName}' for workflow '{WorkflowKey}'")] + public static partial void LogRegisteringWorkflowTrigger(this ILogger logger, string workflowKey, string functionName, string triggerType); + + [LoggerMessage( + EventId = 103, + Level = LogLevel.Information, + Message = "Function metadata transformation complete. Added {AddedCount} workflow function(s). Total function count: {TotalCount}")] + public static partial void LogTransformationComplete(this ILogger logger, int addedCount, int totalCount); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj index ce67c9621e..ae63946d97 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj @@ -4,7 +4,8 @@ $(TargetFrameworksCore) enable - $(NoWarn);CA2007 + + $(NoWarn);CA2007;AD0001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs new file mode 100644 index 0000000000..6f40cbb791 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Extension methods for to configure Azure Functions HTTP trigger options. +/// +public static class DurableWorkflowOptionsExtensions +{ + /// + /// Adds a workflow and optionally exposes a status HTTP endpoint for querying pending HITL requests. + /// + /// The workflow options to add the workflow to. + /// The workflow instance to add. + /// If , a GET endpoint is generated at workflows/{name}/status/{runId}. + public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint) + { + ArgumentNullException.ThrowIfNull(options); + + options.AddWorkflow(workflow); + + if (exposeStatusEndpoint && options.ParentOptions is FunctionsDurableOptions functionsOptions) + { + functionsOptions.EnableStatusEndpoint(workflow.Name!); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs new file mode 100644 index 0000000000..c7ad9a5ebd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// Transforms function metadata by dynamically registering Azure Functions triggers +/// for each configured durable workflow and its executors. +/// +/// +/// For each workflow, this transformer registers: +/// +/// An HTTP trigger function to start the workflow orchestration via HTTP. +/// An orchestration trigger function to run the workflow orchestration. +/// An activity trigger function for each non-agent executor in the workflow. +/// An entity trigger function for each AI agent executor in the workflow. +/// +/// When multiple workflows share the same executor, the corresponding function is registered only once. +/// +internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer +{ + private readonly ILogger _logger; + private readonly FunctionsDurableOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance for diagnostic output. + /// The durable options containing workflow configurations. + public DurableWorkflowsFunctionMetadataTransformer( + ILogger logger, + FunctionsDurableOptions durableOptions) + { + this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); + ArgumentNullException.ThrowIfNull(durableOptions); + this._options = durableOptions; + } + + /// + public string Name => nameof(DurableWorkflowsFunctionMetadataTransformer); + + /// + public void Transform(IList original) + { + int initialCount = original.Count; + this._logger.LogTransformingFunctionMetadata(initialCount); + + // Track registered function names to avoid duplicates when workflows share executors. + HashSet registeredFunctions = []; + + DurableWorkflowOptions workflowOptions = this._options.Workflows; + foreach (var workflow in workflowOptions.Workflows) + { + string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}"; + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Registering durable workflow functions for workflow '{WorkflowKey}' with HTTP trigger function name '{HttpFunctionName}'", workflow.Key, httpFunctionName); + } + + // Register an orchestration function for the workflow. + string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Key); + if (registeredFunctions.Add(orchestrationFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, orchestrationFunctionName, "orchestration"); + original.Add(FunctionMetadataFactory.CreateOrchestrationTrigger( + orchestrationFunctionName, + BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint)); + } + + // Register an HTTP trigger so users can start this workflow via HTTP. + if (registeredFunctions.Add(httpFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, httpFunctionName, "http"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger( + workflow.Key, + $"workflows/{workflow.Key}/run", + BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint)); + } + + // Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true). + if (this._options.IsStatusEndpointEnabled(workflow.Key)) + { + string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-status"; + if (registeredFunctions.Add(statusFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger( + $"{workflow.Key}-status", + $"workflows/{workflow.Key}/status/{{runId}}", + BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, + methods: "\"get\"")); + } + } + + // Register a respond endpoint when the workflow contains RequestPort nodes. + bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding); + if (hasRequestPorts) + { + string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-respond"; + if (registeredFunctions.Add(respondFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger( + $"{workflow.Key}-respond", + $"workflows/{workflow.Key}/respond/{{runId}}", + BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)); + } + } + + // Register activity or entity functions for each executor in the workflow. + // ReflectExecutors() returns all executors across the graph; no need to manually traverse edges. + foreach (KeyValuePair entry in workflow.Value.ReflectExecutors()) + { + // Sub-workflow and RequestPort bindings use specialized dispatch, not activities. + if (entry.Value is SubworkflowBinding or RequestPortBinding) + { + continue; + } + + string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key); + + // AI agent executors are backed by durable entities; other executors use activity triggers. + if (entry.Value is AIAgentBinding) + { + string entityName = AgentSessionId.ToEntityName(executorName); + if (registeredFunctions.Add(entityName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, entityName, "entity"); + original.Add(FunctionMetadataFactory.CreateEntityTrigger(executorName)); + } + } + else + { + string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); + if (registeredFunctions.Add(functionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, functionName, "activity"); + original.Add(FunctionMetadataFactory.CreateActivityTrigger(functionName)); + } + } + } + } + + this._logger.LogTransformationComplete(original.Count - initialCount, original.Count); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs new file mode 100644 index 0000000000..f89abedc23 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.DurableTask; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// A custom implementation that delegates workflow orchestration +/// execution to the . +/// +internal sealed class WorkflowOrchestrator : ITaskOrchestrator +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve workflow dependencies. + public WorkflowOrchestrator(IServiceProvider serviceProvider) + { + this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public Type InputType => typeof(DurableWorkflowInput); + + /// + public Type OutputType => typeof(DurableWorkflowResult); + + /// + public async Task RunAsync(TaskOrchestrationContext context, object? input) + { + ArgumentNullException.ThrowIfNull(context); + + DurableWorkflowRunner runner = this._serviceProvider.GetRequiredService(); + ILogger logger = context.CreateReplaySafeLogger(context.Name); + + DurableWorkflowInput workflowInput = input switch + { + DurableWorkflowInput existing => existing, + _ => new DurableWorkflowInput { Input = input! } + }; + + // ConfigureAwait(true) is required to preserve the orchestration context + // across awaits, which the Durable Task framework uses for replay. + return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj index 27269eb598..c103ead32d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj @@ -25,6 +25,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs index af14a4c8f4..c15405db63 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs @@ -2,46 +2,30 @@ using System.Collections.Concurrent; using System.Diagnostics; -using System.Reflection; using System.Text; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; +/// +/// Integration tests for validating the durable agent console app samples +/// located in samples/Durable/Agents/ConsoleApps. +/// [Collection("Samples")] [Trait("Category", "SampleValidation")] -public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime +public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper) { - private const string DtsPort = "8080"; - private const string RedisPort = "6379"; - - private static readonly string s_dotnetTargetFramework = GetTargetFramework(); - private static readonly IConfiguration s_configuration = - new ConfigurationBuilder() - .AddUserSecrets(Assembly.GetExecutingAssembly()) - .AddEnvironmentVariables() - .Build(); - - private static bool s_infrastructureStarted; private static readonly string s_samplesPath = Path.GetFullPath( Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableAgents", "ConsoleApps")); - private readonly ITestOutputHelper _outputHelper = outputHelper; + /// + protected override string SamplesPath => s_samplesPath; - async ValueTask IAsyncLifetime.InitializeAsync() - { - if (!s_infrastructureStarted) - { - await this.StartSharedInfrastructureAsync(); - s_infrastructureStarted = true; - } - } + /// + protected override bool RequiresRedis => true; - async ValueTask IAsyncDisposable.DisposeAsync() + /// + protected override void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action setEnvVar) { - // Nothing to clean up - await Task.CompletedTask; + setEnvVar("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}"); } [Fact] @@ -474,7 +458,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) // (streams can complete very quickly, so we need to interrupt early) if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2) { - this._outputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines"); + this.OutputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines"); interrupted = true; interruptTime = DateTime.Now; @@ -492,7 +476,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) foundLastCursor = true; // Send Enter again to resume - this._outputHelper.WriteLine("Resuming stream from last cursor"); + this.OutputHelper.WriteLine("Resuming stream from last cursor"); await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token); resumed = true; } @@ -520,7 +504,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) if (timeSinceInterrupt < TimeSpan.FromSeconds(2)) { // Continue reading for a bit more to catch the cancellation message - this._outputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt..."); + this.OutputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt..."); continue; } } @@ -535,7 +519,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) // Stop once we've verified the interrupt/resume flow works if (resumed && foundResumeMessage && contentLinesAfterResume >= 5) { - this._outputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after"); + this.OutputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after"); break; } } @@ -546,7 +530,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value; if (timeSinceInterrupt < TimeSpan.FromSeconds(3)) { - this._outputHelper.WriteLine("Waiting for Last cursor message after interrupt..."); + this.OutputHelper.WriteLine("Waiting for Last cursor message after interrupt..."); using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2)); try { @@ -557,7 +541,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) foundLastCursor = true; if (!resumed) { - this._outputHelper.WriteLine("Resuming stream from last cursor"); + this.OutputHelper.WriteLine("Resuming stream from last cursor"); await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token); resumed = true; } @@ -575,7 +559,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) catch (OperationCanceledException) { // Timeout - check if we got enough to verify the flow - this._outputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}"); + this.OutputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}"); } Assert.True(foundConversationStart, "Conversation start message not found."); @@ -585,7 +569,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) // but we should still verify we got the conversation started if (!interrupted) { - this._outputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast."); + this.OutputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast."); } Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly)."); @@ -595,400 +579,4 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart)."); }); } - - private static string GetTargetFramework() - { - string filePath = new Uri(typeof(ConsoleAppSamplesValidation).Assembly.Location).LocalPath; - string directory = Path.GetDirectoryName(filePath)!; - string tfm = Path.GetFileName(directory); - if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) - { - return tfm; - } - - throw new InvalidOperationException($"Unable to find target framework in path: {filePath}"); - } - - private async Task StartSharedInfrastructureAsync() - { - this._outputHelper.WriteLine("Starting shared infrastructure for console app samples..."); - - // Start DTS emulator - await this.StartDtsEmulatorAsync(); - - // Start Redis - await this.StartRedisAsync(); - - // Wait for infrastructure to be ready - await Task.Delay(TimeSpan.FromSeconds(5)); - } - - private async Task StartDtsEmulatorAsync() - { - // Start DTS emulator if it's not already running - if (!await this.IsDtsEmulatorRunningAsync()) - { - this._outputHelper.WriteLine("Starting DTS emulator..."); - await this.RunCommandAsync("docker", [ - "run", "-d", - "--name", "dts-emulator", - "-p", $"{DtsPort}:8080", - "-e", "DTS_USE_DYNAMIC_TASK_HUBS=true", - "mcr.microsoft.com/dts/dts-emulator:latest" - ]); - } - } - - private async Task StartRedisAsync() - { - if (!await this.IsRedisRunningAsync()) - { - this._outputHelper.WriteLine("Starting Redis..."); - await this.RunCommandAsync("docker", [ - "run", "-d", - "--name", "redis", - "-p", $"{RedisPort}:6379", - "redis:latest" - ]); - } - } - - private async Task IsDtsEmulatorRunningAsync() - { - this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz..."); - - // DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0 - using HttpClient http2Client = new() - { - DefaultRequestVersion = new Version(2, 0), - DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact - }; - - try - { - using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); - using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token); - if (response.Content.Headers.ContentLength > 0) - { - string content = await response.Content.ReadAsStringAsync(timeoutCts.Token); - this._outputHelper.WriteLine($"DTS emulator health check response: {content}"); - } - - if (response.IsSuccessStatusCode) - { - this._outputHelper.WriteLine("DTS emulator is running"); - return true; - } - - this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}"); - return false; - } - catch (HttpRequestException ex) - { - this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}"); - return false; - } - } - - private async Task IsRedisRunningAsync() - { - this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}..."); - - try - { - using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); - ProcessStartInfo startInfo = new() - { - FileName = "docker", - Arguments = "exec redis redis-cli ping", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using Process process = new() { StartInfo = startInfo }; - if (!process.Start()) - { - this._outputHelper.WriteLine("Failed to start docker exec command"); - return false; - } - - string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token); - await process.WaitForExitAsync(timeoutCts.Token); - - if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase)) - { - this._outputHelper.WriteLine("Redis is running"); - return true; - } - - this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}"); - return false; - } - catch (Exception ex) - { - this._outputHelper.WriteLine($"Redis is not running: {ex.Message}"); - return false; - } - } - - private async Task RunSampleTestAsync(string samplePath, Func, Task> testAction) - { - // Build the sample project first (it may not have been built as part of the solution) - await this.BuildSampleAsync(samplePath); - - // Generate a unique TaskHub name for this sample test to prevent cross-test interference - // when multiple tests run together and share the same DTS emulator. - string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}"; - - // Start the console app - // Use BlockingCollection to safely read logs asynchronously captured from the process - using BlockingCollection logsContainer = []; - using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName); - try - { - // Run the test - await testAction(appProcess, logsContainer); - } - catch (OperationCanceledException e) - { - throw new TimeoutException("Core test logic timed out!", e); - } - finally - { - logsContainer.CompleteAdding(); - await this.StopProcessAsync(appProcess); - } - } - - private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); - - /// - /// Writes a line to the process's stdin and flushes it. - /// Logs the input being sent for debugging purposes. - /// - private async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken) - { - this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}"); - await process.StandardInput.WriteLineAsync(input); - await process.StandardInput.FlushAsync(cancellationToken); - } - - /// - /// Reads a line from the logs queue, filtering for Information level logs (stdout). - /// Returns null if the collection is completed and empty, or if cancellation is requested. - /// - private string? ReadLogLine(BlockingCollection logs, CancellationToken cancellationToken) - { - try - { - while (!cancellationToken.IsCancellationRequested) - { - // Block until a log entry is available or cancellation is requested - // Take will throw OperationCanceledException if cancelled, or InvalidOperationException if collection is completed - OutputLog log = logs.Take(cancellationToken); - - // Check for unhandled exceptions in the logs, which are never expected (but can happen) - if (log.Message.Contains("Unhandled exception")) - { - Assert.Fail("Console app encountered an unhandled exception."); - } - - // Only return Information level logs (stdout), skip Error logs (stderr) - if (log.Level == LogLevel.Information) - { - return log.Message; - } - } - } - catch (OperationCanceledException) - { - // Cancellation requested - return null; - } - catch (InvalidOperationException) - { - // Collection is completed and empty - return null; - } - - return null; - } - - private async Task BuildSampleAsync(string samplePath) - { - this._outputHelper.WriteLine($"Building sample at {samplePath}..."); - - ProcessStartInfo buildInfo = new() - { - FileName = "dotnet", - Arguments = $"build --framework {s_dotnetTargetFramework}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - using Process buildProcess = new() { StartInfo = buildInfo }; - buildProcess.Start(); - - // Read both streams asynchronously to avoid deadlocks from filled pipe buffers - Task stdoutTask = buildProcess.StandardOutput.ReadToEndAsync(); - Task stderrTask = buildProcess.StandardError.ReadToEndAsync(); - await buildProcess.WaitForExitAsync(); - - string stderr = await stderrTask; - if (buildProcess.ExitCode != 0) - { - string stdout = await stdoutTask; - throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}"); - } - - this._outputHelper.WriteLine($"Build completed for {samplePath}."); - } - - private Process StartConsoleApp(string samplePath, BlockingCollection logs, string taskHubName) - { - ProcessStartInfo startInfo = new() - { - FileName = "dotnet", - Arguments = $"run --no-build --framework {s_dotnetTargetFramework}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - }; - - string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ?? - throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set."); - string openAiDeployment = s_configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? - throw new InvalidOperationException("The required AZURE_OPENAI_DEPLOYMENT_NAME env variable is not set."); - - void SetAndLogEnvironmentVariable(string key, string value) - { - this._outputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}"); - startInfo.EnvironmentVariables[key] = value; - } - - // Set required environment variables for the app - SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint); - SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME", openAiDeployment); - SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", - $"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None"); - SetAndLogEnvironmentVariable("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}"); - - Process process = new() { StartInfo = startInfo }; - - // Capture the output and error streams asynchronously - // These events fire asynchronously, so we add to the blocking collection which is thread-safe - process.ErrorDataReceived += (sender, e) => - { - if (e.Data != null) - { - string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(err)]: {e.Data}"; - this._outputHelper.WriteLine(logMessage); - Debug.WriteLine(logMessage); - try - { - logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data)); - } - catch (InvalidOperationException) - { - // Collection is completed, ignore - } - } - }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data != null) - { - string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(out)]: {e.Data}"; - this._outputHelper.WriteLine(logMessage); - Debug.WriteLine(logMessage); - try - { - logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data)); - } - catch (InvalidOperationException) - { - // Collection is completed, ignore - } - } - }; - - if (!process.Start()) - { - throw new InvalidOperationException("Failed to start the console app"); - } - - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - - return process; - } - - private async Task RunCommandAsync(string command, string[] args) - { - await this.RunCommandAsync(command, workingDirectory: null, args: args); - } - - private async Task RunCommandAsync(string command, string? workingDirectory, string[] args) - { - ProcessStartInfo startInfo = new() - { - FileName = command, - Arguments = string.Join(" ", args), - WorkingDirectory = workingDirectory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}"); - - using Process process = new() { StartInfo = startInfo }; - process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}"); - process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}"); - if (!process.Start()) - { - throw new InvalidOperationException("Failed to start the command"); - } - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - - using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1)); - await process.WaitForExitAsync(cancellationTokenSource.Token); - - this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}"); - } - - private async Task StopProcessAsync(Process process) - { - try - { - if (!process.HasExited) - { - this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}"); - process.Kill(entireProcessTree: true); - - using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10)); - await process.WaitForExitAsync(timeoutCts.Token); - this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}"); - } - } - catch (Exception ex) - { - this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}"); - } - } - - private CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null) - { - TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60); - return new CancellationTokenSource(testTimeout); - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs new file mode 100644 index 0000000000..5d541f614e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// +/// Base class for sample validation integration tests providing shared infrastructure +/// setup and utility methods for running console app samples. +/// +public abstract class SamplesValidationBase : IAsyncLifetime +{ + protected const string DtsPort = "8080"; + protected const string RedisPort = "6379"; + + protected static readonly string DotnetTargetFramework = GetTargetFramework(); + protected static readonly IConfiguration Configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + // Semaphores for thread-safe initialization of shared infrastructure. + // xUnit may run tests in parallel, so we need to ensure that DTS emulator and Redis + // are started only once across all test instances. Using SemaphoreSlim allows async-safe + // locking, and the double-check pattern (check flag, acquire lock, check flag again) + // minimizes lock contention after initialization is complete. + private static readonly SemaphoreSlim s_dtsInitLock = new(1, 1); + private static readonly SemaphoreSlim s_redisInitLock = new(1, 1); + private static bool s_dtsInfrastructureStarted; + private static bool s_redisInfrastructureStarted; + + protected SamplesValidationBase(ITestOutputHelper outputHelper) + { + this.OutputHelper = outputHelper; + } + + /// + /// Gets the test output helper for logging. + /// + protected ITestOutputHelper OutputHelper { get; } + + /// + /// Gets the base path to the samples directory for this test class. + /// + protected abstract string SamplesPath { get; } + + /// + /// Gets whether this test class requires Redis infrastructure. + /// + protected virtual bool RequiresRedis => false; + + /// + /// Gets the task hub name prefix for this test class. + /// + protected virtual string TaskHubPrefix => "sample"; + + /// + public async ValueTask InitializeAsync() + { + await EnsureDtsInfrastructureStartedAsync(this.OutputHelper, this.StartDtsEmulatorAsync); + + if (this.RequiresRedis) + { + await EnsureRedisInfrastructureStartedAsync(this.OutputHelper, this.StartRedisAsync); + } + + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + /// + /// Ensures DTS infrastructure is started exactly once across all test instances. + /// Static method writes to static field to avoid the code smell of instance methods modifying shared state. + /// + private static async Task EnsureDtsInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func startAction) + { + if (s_dtsInfrastructureStarted) + { + return; + } + + await s_dtsInitLock.WaitAsync(); + try + { + if (!s_dtsInfrastructureStarted) + { + outputHelper.WriteLine("Starting shared DTS infrastructure..."); + await startAction(); + s_dtsInfrastructureStarted = true; + } + } + finally + { + s_dtsInitLock.Release(); + } + } + + /// + /// Ensures Redis infrastructure is started exactly once across all test instances. + /// Static method writes to static field to avoid the code smell of instance methods modifying shared state. + /// + private static async Task EnsureRedisInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func startAction) + { + if (s_redisInfrastructureStarted) + { + return; + } + + await s_redisInitLock.WaitAsync(); + try + { + if (!s_redisInfrastructureStarted) + { + outputHelper.WriteLine("Starting shared Redis infrastructure..."); + await startAction(); + s_redisInfrastructureStarted = true; + } + } + finally + { + s_redisInitLock.Release(); + } + } + + /// + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } + + protected sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); + + /// + /// Runs a sample test by starting the console app and executing the provided test action. + /// + protected async Task RunSampleTestAsync(string samplePath, Func, Task> testAction) + { + string uniqueTaskHubName = $"{this.TaskHubPrefix}-{Guid.NewGuid():N}"[..^26]; + + using BlockingCollection logsContainer = []; + using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName); + + try + { + await testAction(appProcess, logsContainer); + } + catch (OperationCanceledException e) + { + throw new TimeoutException("Core test logic timed out!", e); + } + finally + { + logsContainer.CompleteAdding(); + await this.StopProcessAsync(appProcess); + } + } + + /// + /// Writes a line to the process's stdin and flushes it. + /// + protected async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken) + { + this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}"); + await process.StandardInput.WriteLineAsync(input); + await process.StandardInput.FlushAsync(cancellationToken); + } + + /// + /// Reads the next Information-level log line from the queue. + /// Returns null if cancelled or collection is completed. + /// + protected string? ReadLogLine(BlockingCollection logs, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + OutputLog log = logs.Take(cancellationToken); + + if (log.Message.Contains("Unhandled exception")) + { + Assert.Fail("Console app encountered an unhandled exception."); + } + + if (log.Level == LogLevel.Information) + { + return log.Message; + } + } + } + catch (OperationCanceledException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + + return null; + } + + /// + /// Creates a cancellation token source with the specified timeout for test operations. + /// + protected CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null) + { + TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60); + return new CancellationTokenSource(testTimeout); + } + + /// + /// Allows derived classes to set additional environment variables for the console app process. + /// + protected virtual void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action setEnvVar) + { + } + + private static string GetTargetFramework() + { + string filePath = new Uri(typeof(SamplesValidationBase).Assembly.Location).LocalPath; + string directory = Path.GetDirectoryName(filePath)!; + string tfm = Path.GetFileName(directory); + if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + { + return tfm; + } + + throw new InvalidOperationException($"Unable to find target framework in path: {filePath}"); + } + + private async Task StartDtsEmulatorAsync() + { + if (!await this.IsDtsEmulatorRunningAsync()) + { + this.OutputHelper.WriteLine("Starting DTS emulator..."); + await this.RunCommandAsync("docker", "run", "-d", + "--name", "dts-emulator", + "-p", $"{DtsPort}:8080", + "-e", "DTS_USE_DYNAMIC_TASK_HUBS=true", + "mcr.microsoft.com/dts/dts-emulator:latest"); + } + } + + private async Task StartRedisAsync() + { + if (!await this.IsRedisRunningAsync()) + { + this.OutputHelper.WriteLine("Starting Redis..."); + await this.RunCommandAsync("docker", "run", "-d", + "--name", "redis", + "-p", $"{RedisPort}:6379", + "redis:latest"); + } + } + + private async Task IsDtsEmulatorRunningAsync() + { + this.OutputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz..."); + + using HttpClient http2Client = new() + { + DefaultRequestVersion = new Version(2, 0), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + try + { + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); + using HttpResponseMessage response = await http2Client.GetAsync( + new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token); + + if (response.Content.Headers.ContentLength > 0) + { + string content = await response.Content.ReadAsStringAsync(timeoutCts.Token); + this.OutputHelper.WriteLine($"DTS emulator health check response: {content}"); + } + + bool isRunning = response.IsSuccessStatusCode; + this.OutputHelper.WriteLine(isRunning ? "DTS emulator is running" : $"DTS emulator not running. Status: {response.StatusCode}"); + return isRunning; + } + catch (HttpRequestException ex) + { + this.OutputHelper.WriteLine($"DTS emulator is not running: {ex.Message}"); + return false; + } + } + + private async Task IsRedisRunningAsync() + { + this.OutputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}..."); + + try + { + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); + ProcessStartInfo startInfo = new() + { + FileName = "docker", + Arguments = "exec redis redis-cli ping", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using Process process = new() { StartInfo = startInfo }; + if (!process.Start()) + { + this.OutputHelper.WriteLine("Failed to start docker exec command"); + return false; + } + + string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token); + await process.WaitForExitAsync(timeoutCts.Token); + + bool isRunning = process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase); + this.OutputHelper.WriteLine(isRunning ? "Redis is running" : $"Redis not running. Exit: {process.ExitCode}, Output: {output}"); + return isRunning; + } + catch (Exception ex) + { + this.OutputHelper.WriteLine($"Redis is not running: {ex.Message}"); + return false; + } + } + + private Process StartConsoleApp(string samplePath, BlockingCollection logs, string taskHubName) + { + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + Arguments = $"run --framework {DotnetTargetFramework}", + WorkingDirectory = samplePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + }; + + string openAiEndpoint = Configuration["AZURE_OPENAI_ENDPOINT"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set."); + string openAiDeployment = Configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set."); + + void SetAndLogEnvironmentVariable(string key, string value) + { + this.OutputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}"); + startInfo.EnvironmentVariables[key] = value; + } + + SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint); + SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment); + SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", + $"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None"); + + this.ConfigureAdditionalEnvironmentVariables(startInfo, SetAndLogEnvironmentVariable); + + Process process = new() { StartInfo = startInfo }; + + process.ErrorDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "err", LogLevel.Error, logs); + process.OutputDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "out", LogLevel.Information, logs); + + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the console app"); + } + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + return process; + } + + private void HandleProcessOutput(string? data, string processName, string stream, LogLevel level, BlockingCollection logs) + { + if (data is null) + { + return; + } + + string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{processName}({stream})]: {data}"; + this.OutputHelper.WriteLine(logMessage); + Debug.WriteLine(logMessage); + + try + { + logs.Add(new OutputLog(DateTime.Now, level, data)); + } + catch (InvalidOperationException) + { + // Collection completed + } + } + + private async Task RunCommandAsync(string command, params string[] args) + { + ProcessStartInfo startInfo = new() + { + FileName = command, + Arguments = string.Join(" ", args), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + this.OutputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}"); + + using Process process = new() { StartInfo = startInfo }; + process.ErrorDataReceived += (sender, e) => this.OutputHelper.WriteLine($"[{command}(err)]: {e.Data}"); + process.OutputDataReceived += (sender, e) => this.OutputHelper.WriteLine($"[{command}(out)]: {e.Data}"); + + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the command"); + } + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1)); + await process.WaitForExitAsync(cts.Token); + + this.OutputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}"); + } + + private async Task StopProcessAsync(Process process) + { + try + { + if (!process.HasExited) + { + this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}"); + process.Kill(entireProcessTree: true); + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(cts.Token); + this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}"); + } + } + catch (Exception ex) + { + this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs new file mode 100644 index 0000000000..f137e4abd9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs @@ -0,0 +1,566 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// +/// Integration tests for validating the durable workflow console app samples +/// located in samples/04-hosting/DurableWorkflows/ConsoleApps. +/// +[Collection("Samples")] +[Trait("Category", "SampleValidation")] +public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper) +{ + // In CI, `dotnet run` builds samples from scratch and LLM calls add latency, so 60s is not enough. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromSeconds(180); + + private static readonly string s_samplesPath = Path.GetFullPath( + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableWorkflows", "ConsoleApps")); + + /// + protected override string SamplesPath => s_samplesPath; + + /// + protected override string TaskHubPrefix => "workflow"; + + [Fact] + public async Task SequentialWorkflowSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool inputSent = false; + bool workflowCompleted = false; + bool foundOrderLookup = false; + bool foundOrderCancel = false; + bool foundSendEmail = false; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) + { + await this.WriteInputAsync(process, "12345", testTimeoutCts.Token); + inputSent = true; + } + + if (inputSent) + { + foundOrderLookup |= line.Contains("[Activity] OrderLookup:", StringComparison.Ordinal); + foundOrderCancel |= line.Contains("[Activity] OrderCancel:", StringComparison.Ordinal); + foundSendEmail |= line.Contains("[Activity] SendEmail:", StringComparison.Ordinal); + + if (line.Contains("Workflow completed. Cancellation email sent for order 12345", StringComparison.OrdinalIgnoreCase)) + { + workflowCompleted = true; + break; + } + } + + this.AssertNoError(line); + } + + Assert.True(inputSent, "Input was not sent to the workflow."); + Assert.True(foundOrderLookup, "OrderLookup executor log entry not found."); + Assert.True(foundOrderCancel, "OrderCancel executor log entry not found."); + Assert.True(foundSendEmail, "SendEmail executor log entry not found."); + Assert.True(workflowCompleted, "Workflow did not complete successfully."); + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + + [Fact] + public async Task ConcurrentWorkflowSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool inputSent = false; + bool workflowCompleted = false; + bool foundParseQuestion = false; + bool foundAggregator = false; + bool foundAggregatorReceived2Responses = false; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + if (!inputSent && line.Contains("Enter a science question", StringComparison.OrdinalIgnoreCase)) + { + await this.WriteInputAsync(process, "What is gravity?", testTimeoutCts.Token); + inputSent = true; + } + + if (inputSent) + { + foundParseQuestion |= line.Contains("[ParseQuestion]", StringComparison.Ordinal); + foundAggregator |= line.Contains("[Aggregator]", StringComparison.Ordinal); + foundAggregatorReceived2Responses |= line.Contains("Received 2 AI agent responses", StringComparison.Ordinal); + + if (line.Contains("Aggregation complete", StringComparison.OrdinalIgnoreCase)) + { + workflowCompleted = true; + break; + } + } + + this.AssertNoError(line); + } + + Assert.True(inputSent, "Input was not sent to the workflow."); + Assert.True(foundParseQuestion, "ParseQuestion executor log entry not found."); + Assert.True(foundAggregator, "Aggregator executor log entry not found."); + Assert.True(foundAggregatorReceived2Responses, "Aggregator did not receive 2 AI agent responses."); + Assert.True(workflowCompleted, "Workflow did not complete successfully."); + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + + [Fact] + public async Task ConditionalEdgesWorkflowSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "03_ConditionalEdges"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool validOrderSent = false; + bool blockedOrderSent = false; + bool validOrderCompleted = false; + bool blockedOrderCompleted = false; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + // Send a valid order first (no 'B' in ID) + if (!validOrderSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) + { + await this.WriteInputAsync(process, "12345", testTimeoutCts.Token); + validOrderSent = true; + } + + // Check valid order completed (routed to PaymentProcessor) + if (validOrderSent && !validOrderCompleted && + line.Contains("PaymentReferenceNumber", StringComparison.OrdinalIgnoreCase)) + { + validOrderCompleted = true; + + // Send a blocked order (contains 'B') + await this.WriteInputAsync(process, "ORDER-B-999", testTimeoutCts.Token); + blockedOrderSent = true; + } + + // Check blocked order completed (routed to NotifyFraud) + if (blockedOrderSent && line.Contains("flagged as fraudulent", StringComparison.OrdinalIgnoreCase)) + { + blockedOrderCompleted = true; + break; + } + + this.AssertNoError(line); + } + + Assert.True(validOrderSent, "Valid order input was not sent."); + Assert.True(validOrderCompleted, "Valid order did not complete (PaymentProcessor path)."); + Assert.True(blockedOrderSent, "Blocked order input was not sent."); + Assert.True(blockedOrderCompleted, "Blocked order did not complete (NotifyFraud path)."); + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + + private void AssertNoError(string line) + { + if (line.Contains("Failed:", StringComparison.OrdinalIgnoreCase) || + line.Contains("Error:", StringComparison.OrdinalIgnoreCase)) + { + Assert.Fail($"Workflow failed: {line}"); + } + } + + [Fact] + public async Task WorkflowEventsSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "05_WorkflowEvents"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool inputSent = false; + bool foundStartedRun = false; + bool foundExecutorInvoked = false; + bool foundExecutorCompleted = false; + bool foundLookupStarted = false; + bool foundOrderFound = false; + bool foundCancelProgress = false; + bool foundOrderCancelled = false; + bool foundEmailSent = false; + bool foundYieldedOutput = false; + bool foundWorkflowCompleted = false; + bool foundCompletionResult = false; + List eventLines = []; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + if (!inputSent && line.Contains("Enter order ID", StringComparison.OrdinalIgnoreCase)) + { + await this.WriteInputAsync(process, "12345", testTimeoutCts.Token); + inputSent = true; + } + + if (inputSent) + { + foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal); + foundExecutorInvoked |= line.Contains("ExecutorInvokedEvent", StringComparison.Ordinal); + foundExecutorCompleted |= line.Contains("ExecutorCompletedEvent", StringComparison.Ordinal); + foundLookupStarted |= line.Contains("[Lookup] Looking up order", StringComparison.Ordinal); + foundOrderFound |= line.Contains("[Lookup] Found:", StringComparison.Ordinal); + foundCancelProgress |= line.Contains("[Cancel]", StringComparison.Ordinal) && line.Contains('%'); + foundOrderCancelled |= line.Contains("[Cancel] Done", StringComparison.Ordinal); + foundEmailSent |= line.Contains("[Email] Sent to", StringComparison.Ordinal); + foundYieldedOutput |= line.Contains("[Output]", StringComparison.Ordinal); + foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal); + + if (line.Contains("Completed:", StringComparison.Ordinal)) + { + foundCompletionResult = line.Contains("12345", StringComparison.Ordinal); + break; + } + + // Collect event lines for ordering verification + if (line.Contains("[Lookup]", StringComparison.Ordinal) + || line.Contains("[Cancel]", StringComparison.Ordinal) + || line.Contains("[Email]", StringComparison.Ordinal) + || line.Contains("[Output]", StringComparison.Ordinal)) + { + eventLines.Add(line); + } + } + + this.AssertNoError(line); + } + + Assert.True(inputSent, "Input was not sent to the workflow."); + Assert.True(foundStartedRun, "Streaming run was not started."); + Assert.True(foundExecutorInvoked, "ExecutorInvokedEvent not found in stream."); + Assert.True(foundExecutorCompleted, "ExecutorCompletedEvent not found in stream."); + Assert.True(foundLookupStarted, "OrderLookupStartedEvent not found in stream."); + Assert.True(foundOrderFound, "OrderFoundEvent not found in stream."); + Assert.True(foundCancelProgress, "CancellationProgressEvent not found in stream."); + Assert.True(foundOrderCancelled, "OrderCancelledEvent not found in stream."); + Assert.True(foundEmailSent, "EmailSentEvent not found in stream."); + Assert.True(foundYieldedOutput, "WorkflowOutputEvent not found in stream."); + Assert.True(foundWorkflowCompleted, "DurableWorkflowCompletedEvent not found in stream."); + Assert.True(foundCompletionResult, "Completion result does not contain the order ID."); + + // Verify event ordering: lookup events appear before cancel events, which appear before email events + int lastLookupIndex = eventLines.FindLastIndex(l => l.Contains("[Lookup]", StringComparison.Ordinal)); + int firstCancelIndex = eventLines.FindIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal)); + int lastCancelIndex = eventLines.FindLastIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal)); + int firstEmailIndex = eventLines.FindIndex(l => l.Contains("[Email]", StringComparison.Ordinal)); + + if (lastLookupIndex >= 0 && firstCancelIndex >= 0) + { + Assert.True(lastLookupIndex < firstCancelIndex, "Lookup events should appear before cancel events."); + } + + if (lastCancelIndex >= 0 && firstEmailIndex >= 0) + { + Assert.True(lastCancelIndex < firstEmailIndex, "Cancel events should appear before email events."); + } + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + + [Fact] + public async Task WorkflowSharedStateSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "06_WorkflowSharedState"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool inputSent = false; + bool foundStartedRun = false; + bool foundValidateOutput = false; + bool foundEnrichOutput = false; + bool foundPaymentOutput = false; + bool foundInvoiceOutput = false; + bool foundTaxCalculation = false; + bool foundAuditTrail = false; + bool foundWorkflowCompleted = false; + List outputLines = []; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) + { + await this.WriteInputAsync(process, "ORD-001", testTimeoutCts.Token); + inputSent = true; + } + + if (inputSent) + { + foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal); + + if (line.Contains("[Output]", StringComparison.Ordinal)) + { + foundValidateOutput |= line.Contains("ValidateOrder:", StringComparison.Ordinal) && line.Contains("validated", StringComparison.OrdinalIgnoreCase); + foundEnrichOutput |= line.Contains("EnrichOrder:", StringComparison.Ordinal) && line.Contains("enriched", StringComparison.OrdinalIgnoreCase); + foundPaymentOutput |= line.Contains("ProcessPayment:", StringComparison.Ordinal) && line.Contains("Payment processed", StringComparison.OrdinalIgnoreCase); + foundInvoiceOutput |= line.Contains("GenerateInvoice:", StringComparison.Ordinal) && line.Contains("Invoice complete", StringComparison.OrdinalIgnoreCase); + + // Verify shared state: tax rate was read by ProcessPayment + foundTaxCalculation |= line.Contains("tax:", StringComparison.OrdinalIgnoreCase); + + // Verify shared state: audit trail was accumulated across executors + foundAuditTrail |= line.Contains("Audit trail:", StringComparison.Ordinal) + && line.Contains("ValidateOrder", StringComparison.Ordinal) + && line.Contains("EnrichOrder", StringComparison.Ordinal) + && line.Contains("ProcessPayment", StringComparison.Ordinal); + + outputLines.Add(line); + } + + foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal) + || line.Contains("Completed:", StringComparison.Ordinal); + + if (line.Contains("Completed:", StringComparison.Ordinal)) + { + break; + } + } + + this.AssertNoError(line); + } + + Assert.True(inputSent, "Input was not sent to the workflow."); + Assert.True(foundStartedRun, "Streaming run was not started."); + Assert.True(foundValidateOutput, "ValidateOrder output not found in stream."); + Assert.True(foundEnrichOutput, "EnrichOrder output not found in stream."); + Assert.True(foundPaymentOutput, "ProcessPayment output not found in stream."); + Assert.True(foundInvoiceOutput, "GenerateInvoice output not found in stream."); + Assert.True(foundTaxCalculation, "Tax calculation (shared state read) not found."); + Assert.True(foundAuditTrail, "Audit trail (shared state accumulation) not found."); + Assert.True(foundWorkflowCompleted, "Workflow completion not found in stream."); + + // Verify output ordering: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice + int validateIndex = outputLines.FindIndex(l => l.Contains("ValidateOrder:", StringComparison.Ordinal) && l.Contains("validated", StringComparison.OrdinalIgnoreCase)); + int enrichIndex = outputLines.FindIndex(l => l.Contains("EnrichOrder:", StringComparison.Ordinal)); + int paymentIndex = outputLines.FindIndex(l => l.Contains("ProcessPayment:", StringComparison.Ordinal)); + int invoiceIndex = outputLines.FindIndex(l => l.Contains("GenerateInvoice:", StringComparison.Ordinal)); + + if (validateIndex >= 0 && enrichIndex >= 0) + { + Assert.True(validateIndex < enrichIndex, "ValidateOrder output should appear before EnrichOrder."); + } + + if (enrichIndex >= 0 && paymentIndex >= 0) + { + Assert.True(enrichIndex < paymentIndex, "EnrichOrder output should appear before ProcessPayment."); + } + + if (paymentIndex >= 0 && invoiceIndex >= 0) + { + Assert.True(paymentIndex < invoiceIndex, "ProcessPayment output should appear before GenerateInvoice."); + } + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + + [Fact] + public async Task SubWorkflowsSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "07_SubWorkflows"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool inputSent = false; + bool foundOrderReceived = false; + bool foundValidatePayment = false; + bool foundAnalyzePatterns = false; + bool foundCalculateRiskScore = false; + bool foundChargePayment = false; + bool foundSelectCarrier = false; + bool foundCreateShipment = false; + bool foundOrderCompleted = false; + bool foundFraudRiskEvent = false; + bool workflowCompleted = false; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase)) + { + await this.WriteInputAsync(process, "ORD-001", testTimeoutCts.Token); + inputSent = true; + } + + if (inputSent) + { + // Main workflow executors + foundOrderReceived |= line.Contains("[OrderReceived]", StringComparison.Ordinal); + foundOrderCompleted |= line.Contains("[OrderCompleted]", StringComparison.Ordinal); + + // Payment sub-workflow executors + foundValidatePayment |= line.Contains("[Payment/ValidatePayment]", StringComparison.Ordinal); + foundChargePayment |= line.Contains("[Payment/ChargePayment]", StringComparison.Ordinal); + + // FraudCheck sub-sub-workflow executors (nested inside Payment) + foundAnalyzePatterns |= line.Contains("[Payment/FraudCheck/AnalyzePatterns]", StringComparison.Ordinal); + foundCalculateRiskScore |= line.Contains("[Payment/FraudCheck/CalculateRiskScore]", StringComparison.Ordinal); + + // Shipping sub-workflow executors + foundSelectCarrier |= line.Contains("[Shipping/SelectCarrier]", StringComparison.Ordinal); + foundCreateShipment |= line.Contains("[Shipping/CreateShipment]", StringComparison.Ordinal); + + // Custom event from nested sub-workflow (streamed to client) + foundFraudRiskEvent |= line.Contains("[Event from sub-workflow] FraudRiskAssessedEvent", StringComparison.Ordinal); + + if (line.Contains("Order completed", StringComparison.OrdinalIgnoreCase)) + { + workflowCompleted = true; + break; + } + } + + this.AssertNoError(line); + } + + Assert.True(inputSent, "Input was not sent to the workflow."); + Assert.True(foundOrderReceived, "OrderReceived executor log not found."); + Assert.True(foundValidatePayment, "Payment/ValidatePayment executor log not found."); + Assert.True(foundAnalyzePatterns, "Payment/FraudCheck/AnalyzePatterns executor log not found."); + Assert.True(foundCalculateRiskScore, "Payment/FraudCheck/CalculateRiskScore executor log not found."); + Assert.True(foundChargePayment, "Payment/ChargePayment executor log not found."); + Assert.True(foundSelectCarrier, "Shipping/SelectCarrier executor log not found."); + Assert.True(foundCreateShipment, "Shipping/CreateShipment executor log not found."); + Assert.True(foundOrderCompleted, "OrderCompleted executor log not found."); + Assert.True(foundFraudRiskEvent, "FraudRiskAssessedEvent from nested sub-workflow not found."); + Assert.True(workflowCompleted, "Workflow did not complete successfully."); + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + + [Fact] + public async Task WorkflowHITLSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "08_WorkflowHITL"); + + await this.RunSampleTestAsync(samplePath, (process, logs) => + { + bool foundStarted = false; + bool foundManagerApprovalPause = false; + bool foundManagerApprovalInput = false; + bool foundManagerResponseSent = false; + bool foundBudgetApprovalPause = false; + bool foundBudgetResponseSent = false; + bool foundComplianceApprovalPause = false; + bool foundComplianceResponseSent = false; + bool foundWorkflowCompleted = false; + + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + foundStarted |= line.Contains("Starting expense reimbursement workflow", StringComparison.Ordinal); + foundManagerApprovalPause |= line.Contains("Workflow paused at RequestPort: ManagerApproval", StringComparison.Ordinal); + foundManagerApprovalInput |= line.Contains("Approval for: Jerry", StringComparison.Ordinal); + foundManagerResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundManagerApprovalPause && !foundBudgetApprovalPause && !foundComplianceApprovalPause; + foundBudgetApprovalPause |= line.Contains("Workflow paused at RequestPort: BudgetApproval", StringComparison.Ordinal); + foundBudgetResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundBudgetApprovalPause; + foundComplianceApprovalPause |= line.Contains("Workflow paused at RequestPort: ComplianceApproval", StringComparison.Ordinal); + foundComplianceResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundComplianceApprovalPause; + + if (line.Contains("Workflow completed: Expense reimbursed at", StringComparison.Ordinal)) + { + foundWorkflowCompleted = true; + break; + } + + this.AssertNoError(line); + } + + Assert.True(foundStarted, "Workflow start message not found."); + Assert.True(foundManagerApprovalPause, "Manager approval pause not found."); + Assert.True(foundManagerApprovalInput, "Manager approval input (Jerry) not found."); + Assert.True(foundManagerResponseSent, "Manager approval response not sent."); + Assert.True(foundBudgetApprovalPause, "Budget approval pause not found."); + Assert.True(foundBudgetResponseSent, "Budget approval response not sent."); + Assert.True(foundComplianceApprovalPause, "Compliance approval pause not found."); + Assert.True(foundComplianceResponseSent, "Compliance approval response not sent."); + Assert.True(foundWorkflowCompleted, "Workflow did not complete successfully."); + + return Task.CompletedTask; + }); + } + + [Fact] + public async Task WorkflowAndAgentsSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout); + string samplePath = Path.Combine(s_samplesPath, "04_WorkflowAndAgents"); + + await this.RunSampleTestAsync(samplePath, (process, logs) => + { + // Arrange + bool foundDemo1 = false; + bool foundBiologistResponse = false; + bool foundChemistResponse = false; + bool foundDemo2 = false; + bool foundPhysicsWorkflow = false; + bool foundDemo3 = false; + bool foundExpertTeamWorkflow = false; + bool foundDemo4 = false; + bool foundChemistryWorkflow = false; + bool allDemosCompleted = false; + + // Act + string? line; + while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null) + { + foundDemo1 |= line.Contains("DEMO 1:", StringComparison.Ordinal); + foundBiologistResponse |= line.Contains("Biologist:", StringComparison.Ordinal); + foundChemistResponse |= line.Contains("Chemist:", StringComparison.Ordinal); + foundDemo2 |= line.Contains("DEMO 2:", StringComparison.Ordinal); + foundPhysicsWorkflow |= line.Contains("PhysicsExpertReview", StringComparison.Ordinal); + foundDemo3 |= line.Contains("DEMO 3:", StringComparison.Ordinal); + foundExpertTeamWorkflow |= line.Contains("ExpertTeamReview", StringComparison.Ordinal); + foundDemo4 |= line.Contains("DEMO 4:", StringComparison.Ordinal); + foundChemistryWorkflow |= line.Contains("ChemistryExpertReview", StringComparison.Ordinal); + + if (line.Contains("All demos completed", StringComparison.OrdinalIgnoreCase)) + { + allDemosCompleted = true; + break; + } + + this.AssertNoError(line); + } + + // Assert + Assert.True(foundDemo1, "DEMO 1 (Direct Agent Conversation) not found."); + Assert.True(foundBiologistResponse, "Biologist agent response not found."); + Assert.True(foundChemistResponse, "Chemist agent response not found."); + Assert.True(foundDemo2, "DEMO 2 (Single-Agent Workflow) not found."); + Assert.True(foundPhysicsWorkflow, "PhysicsExpertReview workflow not found."); + Assert.True(foundDemo3, "DEMO 3 (Multi-Agent Workflow) not found."); + Assert.True(foundExpertTeamWorkflow, "ExpertTeamReview workflow not found."); + Assert.True(foundDemo4, "DEMO 4 (Chemistry Workflow) not found."); + Assert.True(foundChemistryWorkflow, "ChemistryExpertReview workflow not found."); + Assert.True(allDemosCompleted, "Sample did not complete all demos successfully."); + + return Task.CompletedTask; + }); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj index d6b34bd6b9..335d8e401b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs new file mode 100644 index 0000000000..e3b549e365 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorTests.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +public sealed class DurableActivityExecutorTests +{ + private static readonly JsonSerializerOptions s_camelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + #region DeserializeInput + + [Fact] + public void DeserializeInput_StringType_ReturnsInputAsIs() + { + // Arrange + const string Input = "hello world"; + + // Act + object result = DurableActivityExecutor.DeserializeInput(Input, typeof(string)); + + // Assert + Assert.Equal("hello world", result); + } + + [Fact] + public void DeserializeInput_SimpleObject_DeserializesCorrectly() + { + // Arrange + string input = JsonSerializer.Serialize(new TestRecord("EXP-001", 100.50m), s_camelCaseOptions); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord)); + + // Assert + TestRecord record = Assert.IsType(result); + Assert.Equal("EXP-001", record.Id); + Assert.Equal(100.50m, record.Amount); + } + + [Fact] + public void DeserializeInput_StringArray_DeserializesDirectly() + { + // Arrange + string input = JsonSerializer.Serialize((string[])["a", "b", "c"]); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(string[])); + + // Assert + string[] array = Assert.IsType(result); + Assert.Equal(["a", "b", "c"], array); + } + + [Fact] + public void DeserializeInput_TypedArrayFromFanIn_DeserializesEachElement() + { + // Arrange — fan-in produces a JSON array of serialized strings + TestRecord r1 = new("EXP-001", 100m); + TestRecord r2 = new("EXP-002", 200m); + string[] serializedElements = + [ + JsonSerializer.Serialize(r1, s_camelCaseOptions), + JsonSerializer.Serialize(r2, s_camelCaseOptions) + ]; + string input = JsonSerializer.Serialize(serializedElements); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])); + + // Assert + TestRecord[] records = Assert.IsType(result); + Assert.Equal(2, records.Length); + Assert.Equal("EXP-001", records[0].Id); + Assert.Equal(100m, records[0].Amount); + Assert.Equal("EXP-002", records[1].Id); + Assert.Equal(200m, records[1].Amount); + } + + [Fact] + public void DeserializeInput_TypedArrayWithSingleElement_DeserializesCorrectly() + { + // Arrange + TestRecord r1 = new("EXP-001", 50m); + string[] serializedElements = [JsonSerializer.Serialize(r1, s_camelCaseOptions)]; + string input = JsonSerializer.Serialize(serializedElements); + + // Act + object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])); + + // Assert + TestRecord[] records = Assert.IsType(result); + Assert.Single(records); + Assert.Equal("EXP-001", records[0].Id); + } + + [Fact] + public void DeserializeInput_TypedArrayWithNullElement_ThrowsInvalidOperationException() + { + // Arrange — one element is "null" + string input = JsonSerializer.Serialize((string[])["null"]); + + // Act & Assert + Assert.Throws( + () => DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]))); + } + + [Fact] + public void DeserializeInput_InvalidJson_ThrowsJsonException() + { + // Arrange + const string Input = "not valid json"; + + // Act & Assert + Assert.ThrowsAny( + () => DurableActivityExecutor.DeserializeInput(Input, typeof(TestRecord))); + } + + #endregion + + #region ResolveInputType + + [Fact] + public void ResolveInputType_NullTypeName_ReturnsFirstSupportedType() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord), typeof(string)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_EmptyTypeName_ReturnsFirstSupportedType() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(string.Empty, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_EmptySupportedTypes_DefaultsToString() + { + // Arrange + HashSet supportedTypes = []; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes); + + // Assert + Assert.Equal(typeof(string), result); + } + + [Fact] + public void ResolveInputType_MatchesByFullName() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(TestRecord).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_MatchesByName() + { + // Arrange + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType("TestRecord", supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_StringArrayFallsBackToSupportedType() + { + // Arrange — fan-in sends string[] but executor expects TestRecord[] + HashSet supportedTypes = [typeof(TestRecord[])]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord[]), result); + } + + [Fact] + public void ResolveInputType_StringFallsBackToSupportedType() + { + // Arrange — executor doesn't support string + HashSet supportedTypes = [typeof(TestRecord)]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(string).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(TestRecord), result); + } + + [Fact] + public void ResolveInputType_StringArrayRetainedWhenSupported() + { + // Arrange — executor explicitly supports string[] + HashSet supportedTypes = [typeof(string[])]; + + // Act + Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes); + + // Assert + Assert.Equal(typeof(string[]), result); + } + + #endregion + + private sealed record TestRecord(string Id, decimal Amount); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs new file mode 100644 index 0000000000..8aef99e3e1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -0,0 +1,765 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +public sealed class DurableStreamingWorkflowRunTests +{ + private const string InstanceId = "test-instance-123"; + private const string WorkflowTestName = "TestWorkflow"; + + private static Workflow CreateTestWorkflow() => + new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)) + .WithName(WorkflowTestName) + .Build(); + + private static OrchestrationMetadata CreateMetadata( + OrchestrationRuntimeStatus status, + string? serializedCustomStatus = null, + string? serializedOutput = null, + TaskFailureDetails? failureDetails = null) + { + return new OrchestrationMetadata(WorkflowTestName, InstanceId) + { + RuntimeStatus = status, + SerializedCustomStatus = serializedCustomStatus, + SerializedOutput = serializedOutput, + FailureDetails = failureDetails, + }; + } + + private static string SerializeCustomStatus(List events) + { + DurableWorkflowLiveStatus status = new() { Events = events }; + return JsonSerializer.Serialize(status, DurableSerialization.Options); + } + + private static string SerializeCustomStatusWithPendingEvents( + List events, + List pendingEvents) + { + DurableWorkflowLiveStatus status = new() { Events = events, PendingEvents = pendingEvents }; + return JsonSerializer.Serialize(status, DurableSerialization.Options); + } + + private static Workflow CreateTestWorkflowWithRequestPort(string requestPortId) + { + FunctionExecutor start = new("start", (_, _, _) => default); + RequestPort requestPort = RequestPort.Create(requestPortId); + FunctionExecutor end = new("end", (_, _, _) => default); + return new WorkflowBuilder(start) + .WithName(WorkflowTestName) + .AddEdge(start, requestPort) + .AddEdge(requestPort, end) + .Build(); + } + + private static string SerializeWorkflowResult(string? result, List events) + { + DurableWorkflowResult workflowResult = new() { Result = result, Events = events }; + return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult); + } + + private static string SerializeEvent(WorkflowEvent evt) + { + Type eventType = evt.GetType(); + TypedPayload wrapper = new() + { + TypeName = eventType.AssemblyQualifiedName, + Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options) + }; + + return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload); + } + + #region Constructor and Properties + + [Fact] + public void Constructor_SetsRunIdAndWorkflowName() + { + // Arrange + Mock mockClient = new("test"); + + // Act + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Assert + Assert.Equal(InstanceId, run.RunId); + Assert.Equal(WorkflowTestName, run.WorkflowName); + } + + [Fact] + public void Constructor_NoWorkflowName_SetsEmptyString() + { + // Arrange + Mock mockClient = new("test"); + Workflow workflow = new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)).Build(); + + // Act + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Assert + Assert.Equal(string.Empty, run.WorkflowName); + } + + #endregion + + #region GetStatusAsync + + [Theory] + [InlineData(OrchestrationRuntimeStatus.Pending, DurableRunStatus.Pending)] + [InlineData(OrchestrationRuntimeStatus.Running, DurableRunStatus.Running)] + [InlineData(OrchestrationRuntimeStatus.Completed, DurableRunStatus.Completed)] + [InlineData(OrchestrationRuntimeStatus.Failed, DurableRunStatus.Failed)] + [InlineData(OrchestrationRuntimeStatus.Terminated, DurableRunStatus.Terminated)] + [InlineData(OrchestrationRuntimeStatus.Suspended, DurableRunStatus.Suspended)] + + public async Task GetStatusAsync_MapsRuntimeStatusCorrectlyAsync( + OrchestrationRuntimeStatus runtimeStatus, + DurableRunStatus expectedStatus) + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny())) + .ReturnsAsync(CreateMetadata(runtimeStatus)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + DurableRunStatus status = await run.GetStatusAsync(); + + // Assert + Assert.Equal(expectedStatus, status); + } + + [Fact] + public async Task GetStatusAsync_InstanceNotFound_ReturnsNotFoundAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny())) + .ReturnsAsync((OrchestrationMetadata?)null); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + DurableRunStatus status = await run.GetStatusAsync(); + + // Assert + Assert.Equal(DurableRunStatus.NotFound, status); + } + + #endregion + + #region WatchStreamAsync + + [Fact] + public async Task WatchStreamAsync_InstanceNotFound_YieldsNoEventsAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync((OrchestrationMetadata?)null); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Empty(events); + } + + [Fact] + public async Task WatchStreamAsync_CompletedWithResult_YieldsCompletedEventAsync() + { + // Arrange + string serializedOutput = SerializeWorkflowResult("done", []); + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Single(events); + DurableWorkflowCompletedEvent completedEvent = Assert.IsType(events[0]); + Assert.Equal("done", completedEvent.Data); + } + + [Fact] + public async Task WatchStreamAsync_CompletedWithEventsInOutput_YieldsEventsAndCompletionAsync() + { + // Arrange + DurableHaltRequestedEvent haltEvent = new("executor-1"); + string serializedEvent = SerializeEvent(haltEvent); + string serializedOutput = SerializeWorkflowResult("result", [serializedEvent]); + + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Equal(2, events.Count); + DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); + Assert.Equal("executor-1", haltResult.ExecutorId); + DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); + Assert.Equal("result", completedResult.Result); + } + + [Fact] + public async Task WatchStreamAsync_CompletedWithoutWrapper_YieldsFailedEventAsync() + { + // Arrange — output not wrapped in DurableWorkflowResult (indicates a bug) + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: "\"raw output\"")); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert — yields a failed event with diagnostic message instead of crashing + Assert.Single(events); + DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); + Assert.Contains("could not be parsed", failedEvent.ErrorMessage); + } + + [Fact] + public async Task WatchStreamAsync_Failed_YieldsFailedEventAsync() + { + // Arrange + Mock mockClient = new("test"); + TaskFailureDetails failureDetails = new("ErrorType", "Something went wrong", null, null, null); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata( + OrchestrationRuntimeStatus.Failed, + failureDetails: failureDetails)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Single(events); + DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); + Assert.Equal("Something went wrong", failedEvent.ErrorMessage); + Assert.NotNull(failedEvent.FailureDetails); + Assert.Equal("ErrorType", failedEvent.FailureDetails.ErrorType); + Assert.Equal("Something went wrong", failedEvent.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task WatchStreamAsync_FailedWithNoDetails_YieldsDefaultMessageAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Failed)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Single(events); + DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); + Assert.Equal("Workflow execution failed.", failedEvent.ErrorMessage); + Assert.Null(failedEvent.FailureDetails); + } + + [Fact] + public async Task WatchStreamAsync_Terminated_YieldsFailedEventAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Single(events); + DurableWorkflowFailedEvent failedEvent = Assert.IsType(events[0]); + Assert.Equal("Workflow was terminated.", failedEvent.ErrorMessage); + Assert.Null(failedEvent.FailureDetails); + } + + [Fact] + public async Task WatchStreamAsync_EventsInCustomStatus_YieldsEventsBeforeCompletionAsync() + { + // Arrange + DurableHaltRequestedEvent haltEvent = new("exec-1"); + string serializedEvent = SerializeEvent(haltEvent); + string customStatus = SerializeCustomStatus([serializedEvent]); + string serializedOutput = SerializeWorkflowResult("final", []); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus); + } + + return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); + }); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Equal(2, events.Count); + DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); + Assert.Equal("exec-1", haltResult.ExecutorId); + DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); + Assert.Equal("final", completedResult.Result); + } + + [Fact] + public async Task WatchStreamAsync_IncrementalEvents_YieldsOnlyNewEventsPerPollAsync() + { + // Arrange — simulate 3 poll cycles where events accumulate in custom status, + // then a final completion poll. This validates: + // 1. Events arriving across multiple poll cycles are yielded incrementally + // 2. Already-seen events are not re-yielded (lastReadEventIndex dedup) + // 3. Completion event follows all streamed events + DurableHaltRequestedEvent event1 = new("executor-1"); + DurableHaltRequestedEvent event2 = new("executor-2"); + DurableHaltRequestedEvent event3 = new("executor-3"); + + string serializedEvent1 = SerializeEvent(event1); + string serializedEvent2 = SerializeEvent(event2); + string serializedEvent3 = SerializeEvent(event3); + + // Poll 1: 1 event in custom status + string customStatus1 = SerializeCustomStatus([serializedEvent1]); + // Poll 2: same event + 1 new event (accumulating list) + string customStatus2 = SerializeCustomStatus([serializedEvent1, serializedEvent2]); + // Poll 3: all 3 events accumulated + string customStatus3 = SerializeCustomStatus([serializedEvent1, serializedEvent2, serializedEvent3]); + // Poll 4: completed, all events also in output + string serializedOutput = SerializeWorkflowResult("done", [serializedEvent1, serializedEvent2, serializedEvent3]); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount switch + { + 1 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus1), + 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus2), + 3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus3), + _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), + }; + }); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert — exactly 4 events: 3 incremental halt events + 1 completion + Assert.Equal(4, events.Count); + DurableHaltRequestedEvent halt1 = Assert.IsType(events[0]); + DurableHaltRequestedEvent halt2 = Assert.IsType(events[1]); + DurableHaltRequestedEvent halt3 = Assert.IsType(events[2]); + Assert.Equal("executor-1", halt1.ExecutorId); + Assert.Equal("executor-2", halt2.ExecutorId); + Assert.Equal("executor-3", halt3.ExecutorId); + DurableWorkflowCompletedEvent completed = Assert.IsType(events[3]); + Assert.Equal("done", completed.Data); + } + + [Fact] + public async Task WatchStreamAsync_NoNewEventsOnRepoll_DoesNotDuplicateAsync() + { + // Arrange — simulate polling where custom status doesn't change between polls, + // validating that events are not duplicated when the list is unchanged. + DurableHaltRequestedEvent event1 = new("executor-1"); + string serializedEvent1 = SerializeEvent(event1); + string customStatus = SerializeCustomStatus([serializedEvent1]); + string serializedOutput = SerializeWorkflowResult("result", [serializedEvent1]); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount switch + { + // First 3 polls return the same custom status (no new events after first) + <= 3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus), + _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), + }; + }); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert — event1 appears exactly once despite 3 polls with the same status + Assert.Equal(2, events.Count); + DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); + Assert.Equal("executor-1", haltResult.ExecutorId); + DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); + Assert.Equal("result", completedResult.Result); + } + + [Fact] + public async Task WatchStreamAsync_Cancellation_EndsGracefullyAsync() + { + // Arrange + using CancellationTokenSource cts = new(); + int pollCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + if (++pollCount >= 2) + { + cts.Cancel(); + } + + return CreateMetadata(OrchestrationRuntimeStatus.Running); + }); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token)) + { + events.Add(evt); + } + + // Assert — no exception thrown, stream ends cleanly + Assert.Empty(events); + } + + [Fact] + public async Task WatchStreamAsync_PendingRequestPort_YieldsWaitingForInputEventAsync() + { + // Arrange + string customStatus = SerializeCustomStatusWithPendingEvents( + [], + [new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]); + string serializedOutput = SerializeWorkflowResult("approved", []); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 + ? CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus) + : CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); + }); + + Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Equal(2, events.Count); + DurableWorkflowWaitingForInputEvent waitingEvent = Assert.IsType(events[0]); + Assert.Equal("ApprovalPort", waitingEvent.RequestPort.Id); + Assert.Contains("amount", waitingEvent.Input); + DurableWorkflowCompletedEvent completedEvent = Assert.IsType(events[1]); + Assert.Equal("approved", completedEvent.Result); + } + + [Fact] + public async Task WatchStreamAsync_PendingRequestPort_DoesNotDuplicateOnSubsequentPollsAsync() + { + // Arrange — same pending event across 2 polls, then completion + string customStatus = SerializeCustomStatusWithPendingEvents( + [], + [new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]); + string serializedOutput = SerializeWorkflowResult("done", []); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount switch + { + <= 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus), + _ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput), + }; + }); + + Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert — WaitingForInputEvent yielded only once despite 2 polls + Assert.Equal(2, events.Count); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + #endregion + + #region SendResponseAsync + + [Fact] + public async Task SendResponseAsync_SerializesAndRaisesEventAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.RaiseEventAsync( + InstanceId, + "ApprovalPort", + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + RequestPort approvalPort = RequestPort.Create("ApprovalPort"); + DurableWorkflowWaitingForInputEvent requestEvent = new("""{"amount":100}""", approvalPort); + Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow); + + // Act + await run.SendResponseAsync(requestEvent, new { approved = true, comments = "Looks good" }); + + // Assert + mockClient.Verify(c => c.RaiseEventAsync( + InstanceId, + "ApprovalPort", + It.Is(s => s.Contains("approved") && s.Contains("true")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendResponseAsync_NullRequestEvent_ThrowsAsync() + { + // Arrange + Mock mockClient = new("test"); + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync(() => + run.SendResponseAsync(null!, "response").AsTask()); + } + + #endregion + + #region WaitForCompletionAsync + + [Fact] + public async Task WaitForCompletionAsync_Completed_ReturnsResultAsync() + { + // Arrange + string serializedOutput = SerializeWorkflowResult("hello world", []); + Mock mockClient = new("test"); + mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + string? result = await run.WaitForCompletionAsync(); + + // Assert + Assert.Equal("hello world", result); + } + + [Fact] + public async Task WaitForCompletionAsync_Failed_ThrowsTaskFailedExceptionAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata( + OrchestrationRuntimeStatus.Failed, + failureDetails: new TaskFailureDetails("Error", "kaboom", null, null, null))); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act & Assert + TaskFailedException ex = await Assert.ThrowsAsync( + () => run.WaitForCompletionAsync().AsTask()); + Assert.Equal("kaboom", ex.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task WaitForCompletionAsync_UnexpectedStatus_ThrowsAsync() + { + // Arrange + Mock mockClient = new("test"); + mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated)); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync( + () => run.WaitForCompletionAsync().AsTask()); + } + + #endregion + + #region ExtractResult + + [Fact] + public void ExtractResult_NullOutput_ReturnsDefault() + { + // Act + string? result = DurableStreamingWorkflowRun.ExtractResult(null); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ExtractResult_WrappedStringResult_ReturnsUnwrappedString() + { + // Arrange + string serializedOutput = SerializeWorkflowResult("hello", []); + + // Act + string? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); + + // Assert + Assert.Equal("hello", result); + } + + [Fact] + public void ExtractResult_UnwrappedOutput_ThrowsInvalidOperationException() + { + // Arrange — raw output not wrapped in DurableWorkflowResult + string serializedOutput = JsonSerializer.Serialize("raw value"); + + // Act & Assert + Assert.Throws( + () => DurableStreamingWorkflowRun.ExtractResult(serializedOutput)); + } + + [Fact] + public void ExtractResult_WrappedObjectResult_DeserializesCorrectly() + { + // Arrange + TestPayload original = new() { Name = "test", Value = 42 }; + string resultJson = JsonSerializer.Serialize(original); + string serializedOutput = SerializeWorkflowResult(resultJson, []); + + // Act + TestPayload? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); + + // Assert + Assert.NotNull(result); + Assert.Equal("test", result.Name); + Assert.Equal(42, result.Value); + } + + [Fact] + public void ExtractResult_CamelCaseSerializedObject_DeserializesToPascalCaseMembers() + { + // Arrange — executor outputs are serialized with DurableSerialization.Options (camelCase) + TestPayload original = new() { Name = "camel", Value = 99 }; + string resultJson = JsonSerializer.Serialize(original, DurableSerialization.Options); + string serializedOutput = SerializeWorkflowResult(resultJson, []); + + // Act + TestPayload? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); + + // Assert + Assert.NotNull(result); + Assert.Equal("camel", result.Name); + Assert.Equal(99, result.Value); + } + + #endregion + + private sealed class TestPayload + { + public string? Name { get; set; } + + public int Value { get; set; } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs new file mode 100644 index 0000000000..4ceba544a2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowContextTests.cs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +public sealed class DurableWorkflowContextTests +{ + private static FunctionExecutor CreateTestExecutor(string id = "test-executor") + => new(id, (_, _, _) => default, outputTypes: [typeof(string)]); + + #region ReadStateAsync + + [Fact] + public async Task ReadStateAsync_KeyExistsInInitialState_ReturnsValueAsync() + { + // Arrange + Dictionary state = new() { ["__default__:counter"] = "42" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + + // Act + int? result = await context.ReadStateAsync("counter"); + + // Assert + Assert.Equal(42, result); + } + + [Fact] + public async Task ReadStateAsync_KeyDoesNotExist_ReturnsNullAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + string? result = await context.ReadStateAsync("missing"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task ReadStateAsync_LocalUpdateTakesPriorityOverInitialStateAsync() + { + // Arrange + Dictionary state = new() { ["__default__:key"] = "\"old\"" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + await context.QueueStateUpdateAsync("key", "new"); + + // Act + string? result = await context.ReadStateAsync("key"); + + // Assert + Assert.Equal("new", result); + } + + [Fact] + public async Task ReadStateAsync_ScopeCleared_IgnoresInitialStateAsync() + { + // Arrange + Dictionary state = new() { ["__default__:key"] = "\"value\"" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + await context.QueueClearScopeAsync(); + + // Act + string? result = await context.ReadStateAsync("key"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task ReadStateAsync_WithNamedScope_ReadsFromCorrectScopeAsync() + { + // Arrange + Dictionary state = new() + { + ["scopeA:key"] = "\"fromA\"", + ["scopeB:key"] = "\"fromB\"" + }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + + // Act + string? resultA = await context.ReadStateAsync("key", "scopeA"); + string? resultB = await context.ReadStateAsync("key", "scopeB"); + + // Assert + Assert.Equal("fromA", resultA); + Assert.Equal("fromB", resultB); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ReadStateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key) + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => context.ReadStateAsync(key!).AsTask()); + } + + #endregion + + #region ReadOrInitStateAsync + + [Fact] + public async Task ReadOrInitStateAsync_KeyDoesNotExist_CallsFactoryAndQueuesUpdateAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + string result = await context.ReadOrInitStateAsync("key", () => "initialized"); + + // Assert + Assert.Equal("initialized", result); + Assert.True(context.StateUpdates.ContainsKey("__default__:key")); + } + + [Fact] + public async Task ReadOrInitStateAsync_KeyExists_ReturnsExistingValueAsync() + { + // Arrange + Dictionary state = new() { ["__default__:key"] = "\"existing\"" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + bool factoryCalled = false; + + // Act + string result = await context.ReadOrInitStateAsync("key", () => + { + factoryCalled = true; + return "should-not-be-used"; + }); + + // Assert + Assert.Equal("existing", result); + Assert.False(factoryCalled); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task ReadOrInitStateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key) + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act & Assert + await Assert.ThrowsAnyAsync( + () => context.ReadOrInitStateAsync(key!, () => "value").AsTask()); + } + + [Fact] + public async Task ReadOrInitStateAsync_ValueType_MissingKey_CallsFactoryAsync() + { + // Arrange + // Validates that ReadStateAsync returns null (not 0) for missing keys, + // because the return type is int? (Nullable). This ensures the factory + // is correctly invoked for value types when the key does not exist. + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + int result = await context.ReadOrInitStateAsync("counter", () => 42); + + // Assert + Assert.Equal(42, result); + Assert.True(context.StateUpdates.ContainsKey("__default__:counter")); + } + + [Fact] + public async Task ReadOrInitStateAsync_NullFactory_ThrowsArgumentNullExceptionAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act & Assert + await Assert.ThrowsAsync( + () => context.ReadOrInitStateAsync("key", null!).AsTask()); + } + + #endregion + + #region QueueStateUpdateAsync + + [Fact] + public async Task QueueStateUpdateAsync_SetsValue_VisibleToSubsequentReadAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + await context.QueueStateUpdateAsync("key", "hello"); + string? result = await context.ReadStateAsync("key"); + + // Assert + Assert.Equal("hello", result); + } + + [Fact] + public async Task QueueStateUpdateAsync_NullValue_RecordsDeletionAsync() + { + // Arrange + Dictionary state = new() { ["__default__:key"] = "\"value\"" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + + // Act + await context.QueueStateUpdateAsync("key", null); + + // Assert + Assert.True(context.StateUpdates.ContainsKey("__default__:key")); + Assert.Null(context.StateUpdates["__default__:key"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task QueueStateUpdateAsync_NullOrEmptyKey_ThrowsArgumentExceptionAsync(string? key) + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act & Assert + await Assert.ThrowsAnyAsync( + () => context.QueueStateUpdateAsync(key!, "value").AsTask()); + } + + #endregion + + #region QueueClearScopeAsync + + [Fact] + public async Task QueueClearScopeAsync_DefaultScope_ClearsStateAndPendingUpdatesAsync() + { + // Arrange + Dictionary state = new() { ["__default__:key"] = "\"value\"" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + await context.QueueStateUpdateAsync("pending", "data"); + + // Act + await context.QueueClearScopeAsync(); + + // Assert + Assert.Contains("__default__", context.ClearedScopes); + Assert.Empty(context.StateUpdates); + } + + [Fact] + public async Task QueueClearScopeAsync_NamedScope_OnlyClearsThatScopeAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + await context.QueueStateUpdateAsync("keyA", "valueA", scopeName: "scopeA"); + await context.QueueStateUpdateAsync("keyB", "valueB", scopeName: "scopeB"); + + // Act + await context.QueueClearScopeAsync("scopeA"); + + // Assert + Assert.DoesNotContain("scopeA:keyA", context.StateUpdates.Keys); + Assert.Contains("scopeB:keyB", context.StateUpdates.Keys); + } + + #endregion + + #region ReadStateKeysAsync + + [Fact] + public async Task ReadStateKeysAsync_ReturnsKeysFromInitialStateAsync() + { + // Arrange + Dictionary state = new() + { + ["__default__:alpha"] = "\"a\"", + ["__default__:beta"] = "\"b\"" + }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + + // Act + HashSet keys = await context.ReadStateKeysAsync(); + + // Assert + Assert.Equal(2, keys.Count); + Assert.Contains("alpha", keys); + Assert.Contains("beta", keys); + } + + [Fact] + public async Task ReadStateKeysAsync_MergesLocalUpdatesAndDeletionsAsync() + { + // Arrange + Dictionary state = new() + { + ["__default__:existing"] = "\"val\"", + ["__default__:toDelete"] = "\"val\"" + }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + await context.QueueStateUpdateAsync("newKey", "value"); + await context.QueueStateUpdateAsync("toDelete", null); + + // Act + HashSet keys = await context.ReadStateKeysAsync(); + + // Assert + Assert.Contains("existing", keys); + Assert.Contains("newKey", keys); + Assert.DoesNotContain("toDelete", keys); + } + + [Fact] + public async Task ReadStateKeysAsync_AfterClearScope_ExcludesInitialStateAsync() + { + // Arrange + Dictionary state = new() { ["__default__:old"] = "\"val\"" }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + await context.QueueClearScopeAsync(); + await context.QueueStateUpdateAsync("new", "value"); + + // Act + HashSet keys = await context.ReadStateKeysAsync(); + + // Assert + Assert.DoesNotContain("old", keys); + Assert.Contains("new", keys); + } + + [Fact] + public async Task ReadStateKeysAsync_WithNamedScope_OnlyReturnsKeysFromThatScopeAsync() + { + // Arrange + Dictionary state = new() + { + ["scopeA:key1"] = "\"val\"", + ["scopeB:key2"] = "\"val\"" + }; + DurableWorkflowContext context = new(state, CreateTestExecutor()); + + // Act + HashSet keysA = await context.ReadStateKeysAsync("scopeA"); + + // Assert + Assert.Single(keysA); + Assert.Contains("key1", keysA); + } + + #endregion + + #region AddEventAsync + + [Fact] + public async Task AddEventAsync_AddsEventToCollectionAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + WorkflowEvent evt = new ExecutorInvokedEvent("test", "test-data"); + + // Act + await context.AddEventAsync(evt); + + // Assert + Assert.Single(context.OutboundEvents); + Assert.Same(evt, context.OutboundEvents[0]); + } + + [Fact] + public async Task AddEventAsync_NullEvent_DoesNotAddAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await context.AddEventAsync(null); +#pragma warning restore CS8625 + + // Assert + Assert.Empty(context.OutboundEvents); + } + + #endregion + + #region SendMessageAsync + + [Fact] + public async Task SendMessageAsync_SerializesMessageWithTypeNameAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + await context.SendMessageAsync("hello"); + + // Assert + Assert.Single(context.SentMessages); + Assert.Equal(typeof(string).AssemblyQualifiedName, context.SentMessages[0].TypeName); + Assert.NotNull(context.SentMessages[0].Data); + } + + [Fact] + public async Task SendMessageAsync_NullMessage_DoesNotAddAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await context.SendMessageAsync(null); +#pragma warning restore CS8625 + + // Assert + Assert.Empty(context.SentMessages); + } + + #endregion + + #region YieldOutputAsync + + [Fact] + public async Task YieldOutputAsync_AddsWorkflowOutputEventAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + await context.YieldOutputAsync("result"); + + // Assert + Assert.Single(context.OutboundEvents); + WorkflowOutputEvent outputEvent = Assert.IsType(context.OutboundEvents[0]); + Assert.Equal("result", outputEvent.Data); + } + + [Fact] + public async Task YieldOutputAsync_NullOutput_DoesNotAddAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + await context.YieldOutputAsync(null); +#pragma warning restore CS8625 + + // Assert + Assert.Empty(context.OutboundEvents); + } + + #endregion + + #region RequestHaltAsync + + [Fact] + public async Task RequestHaltAsync_SetsHaltRequestedAndAddsEventAsync() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Act + await context.RequestHaltAsync(); + + // Assert + Assert.True(context.HaltRequested); + Assert.Single(context.OutboundEvents); + Assert.IsType(context.OutboundEvents[0]); + } + + #endregion + + #region Properties + + [Fact] + public void TraceContext_ReturnsNull() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Assert + Assert.Null(context.TraceContext); + } + + [Fact] + public void ConcurrentRunsEnabled_ReturnsFalse() + { + // Arrange + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Assert + Assert.False(context.ConcurrentRunsEnabled); + } + + [Fact] + public async Task Constructor_NullInitialState_CreatesEmptyStateAsync() + { + // Arrange & Act + DurableWorkflowContext context = new(null, CreateTestExecutor()); + + // Assert + string? result = await context.ReadStateAsync("anything"); + Assert.Null(result); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs new file mode 100644 index 0000000000..780cf1275d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/WorkflowNamingHelperTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +public sealed class WorkflowNamingHelperTests +{ + [Fact] + public void ToOrchestrationFunctionName_ValidWorkflowName_ReturnsPrefixedName() + { + string result = WorkflowNamingHelper.ToOrchestrationFunctionName("MyWorkflow"); + + Assert.Equal("dafx-MyWorkflow", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ToOrchestrationFunctionName_NullOrEmpty_ThrowsArgumentException(string? workflowName) + { + Assert.ThrowsAny(() => WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName!)); + } + + [Fact] + public void ToWorkflowName_ValidOrchestrationFunctionName_ReturnsWorkflowName() + { + string result = WorkflowNamingHelper.ToWorkflowName("dafx-MyWorkflow"); + + Assert.Equal("MyWorkflow", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ToWorkflowName_NullOrEmpty_ThrowsArgumentException(string? orchestrationFunctionName) + { + Assert.ThrowsAny(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName!)); + } + + [Theory] + [InlineData("MyWorkflow")] + [InlineData("invalid-prefix-MyWorkflow")] + [InlineData("dafx")] + [InlineData("dafx-")] + public void ToWorkflowName_InvalidOrMissingPrefix_ThrowsArgumentException(string orchestrationFunctionName) + { + Assert.Throws(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName)); + } + + [Fact] + public void GetExecutorName_SimpleExecutorId_ReturnsSameName() + { + string result = WorkflowNamingHelper.GetExecutorName("OrderParser"); + + Assert.Equal("OrderParser", result); + } + + [Fact] + public void GetExecutorName_ExecutorIdWithGuidSuffix_ReturnsNameWithoutSuffix() + { + string result = WorkflowNamingHelper.GetExecutorName("Physicist_8884e71021334ce49517fa2b17b1695b"); + + Assert.Equal("Physicist", result); + } + + [Fact] + public void GetExecutorName_NameWithUnderscoresAndGuidSuffix_ReturnsFullName() + { + string result = WorkflowNamingHelper.GetExecutorName("my_agent_8884e71021334ce49517fa2b17b1695b"); + + Assert.Equal("my_agent", result); + } + + [Fact] + public void GetExecutorName_NameWithUnderscoreButNoGuidSuffix_ReturnsSameName() + { + string result = WorkflowNamingHelper.GetExecutorName("my_custom_executor"); + + Assert.Equal("my_custom_executor", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void GetExecutorName_NullOrEmpty_ThrowsArgumentException(string? executorId) + { + Assert.ThrowsAny(() => WorkflowNamingHelper.GetExecutorName(executorId!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index c7004e6ba5..bd88c55cb8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -30,6 +30,10 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi private static bool s_infrastructureStarted; private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1); + + // In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough. + private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); + private static readonly string s_samplesPath = Path.GetFullPath( Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableAgents", "AzureFunctions")); @@ -930,7 +934,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi } }, message: "Azure Functions Core Tools is ready", - timeout: TimeSpan.FromSeconds(60)); + timeout: s_functionsReadyTimeout); } private async Task WaitForOrchestrationCompletionAsync(Uri statusUri) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs new file mode 100644 index 0000000000..d5ea083894 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -0,0 +1,587 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; + +/// +/// Integration tests for validating the durable workflow Azure Functions samples +/// located in samples/04-hosting/DurableWorkflows/AzureFunctions. +/// +[Collection("Samples")] +[Trait("Category", "SampleValidation")] +public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime +{ + private const string AzureFunctionsPort = "7071"; + private const string AzuritePort = "10000"; + private const string DtsPort = "8080"; + + private static readonly string s_dotnetTargetFramework = GetTargetFramework(); + private static readonly HttpClient s_sharedHttpClient = new(); + private static readonly IConfiguration s_configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private static bool s_infrastructureStarted; + private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1); + + // In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough. + private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); + + private static readonly string s_samplesPath = Path.GetFullPath( + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableWorkflows", "AzureFunctions")); + + private readonly ITestOutputHelper _outputHelper = outputHelper; + + public async ValueTask InitializeAsync() + { + if (!s_infrastructureStarted) + { + await this.StartSharedInfrastructureAsync(); + s_infrastructureStarted = true; + } + } + + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } + + [Fact] + public async Task SequentialWorkflowSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => + { + // Test the CancelOrder workflow + Uri cancelOrderUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/CancelOrder/run"); + this._outputHelper.WriteLine($"Starting CancelOrder workflow via POST request to {cancelOrderUri}..."); + + using HttpContent cancelContent = new StringContent("12345", Encoding.UTF8, "text/plain"); + using HttpResponseMessage cancelResponse = await s_sharedHttpClient.PostAsync(cancelOrderUri, cancelContent); + + Assert.True(cancelResponse.IsSuccessStatusCode, $"CancelOrder request failed with status: {cancelResponse.StatusCode}"); + string cancelResponseText = await cancelResponse.Content.ReadAsStringAsync(); + Assert.Contains("CancelOrder", cancelResponseText); + this._outputHelper.WriteLine($"CancelOrder response: {cancelResponseText}"); + + // Wait for the CancelOrder workflow to complete by checking logs + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow completed")); + return Task.FromResult(exists); + } + }, + message: "CancelOrder workflow completed", + timeout: s_orchestrationTimeout); + + // Verify the executor activities ran in sequence + lock (logs) + { + Assert.True(logs.Any(log => log.Message.Contains("[Activity] OrderLookup:")), "OrderLookup activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] OrderCancel:")), "OrderCancel activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] SendEmail:")), "SendEmail activity not found in logs."); + } + + // Test the OrderStatus workflow (shares OrderLookup executor with CancelOrder) + Uri orderStatusUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/OrderStatus/run"); + this._outputHelper.WriteLine($"Starting OrderStatus workflow via POST request to {orderStatusUri}..."); + + using HttpContent statusContent = new StringContent("67890", Encoding.UTF8, "text/plain"); + using HttpResponseMessage statusResponse = await s_sharedHttpClient.PostAsync(orderStatusUri, statusContent); + + Assert.True(statusResponse.IsSuccessStatusCode, $"OrderStatus request failed with status: {statusResponse.StatusCode}"); + string statusResponseText = await statusResponse.Content.ReadAsStringAsync(); + Assert.Contains("OrderStatus", statusResponseText); + this._outputHelper.WriteLine($"OrderStatus response: {statusResponseText}"); + + // Wait for the OrderStatus workflow to complete + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + // Look for StatusReport activity which is unique to OrderStatus workflow + bool exists = logs.Any(log => log.Message.Contains("[Activity] StatusReport:")); + return Task.FromResult(exists); + } + }, + message: "OrderStatus workflow completed", + timeout: s_orchestrationTimeout); + }); + } + + [Fact] + public async Task HITLWorkflowSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => + { + // Use a unique run ID to avoid conflicts with previous test runs + string runId = $"hitl-test-{Guid.NewGuid():N}"; + + // Step 1: Start the expense reimbursement workflow + Uri runUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/run?runId={runId}"); + this._outputHelper.WriteLine($"Starting ExpenseReimbursement workflow via POST request to {runUri}..."); + + using HttpContent runContent = new StringContent("EXP-2025-001", Encoding.UTF8, "text/plain"); + using HttpResponseMessage runResponse = await s_sharedHttpClient.PostAsync(runUri, runContent); + + Assert.True(runResponse.IsSuccessStatusCode, $"Run request failed with status: {runResponse.StatusCode}"); + string runResponseText = await runResponse.Content.ReadAsStringAsync(); + Assert.Contains("ExpenseReimbursement", runResponseText); + this._outputHelper.WriteLine($"Run response: {runResponseText}"); + + // Step 2: Wait for the workflow to pause at the ManagerApproval RequestPort + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'ManagerApproval'")); + return Task.FromResult(exists); + } + }, + message: "Workflow paused at ManagerApproval RequestPort", + timeout: s_orchestrationTimeout); + + // Step 3: Send approval response to resume the workflow + Uri respondUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/respond/{runId}"); + this._outputHelper.WriteLine($"Sending approval response via POST request to {respondUri}..."); + + using HttpContent respondContent = new StringContent( + """{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by test."}}""", + Encoding.UTF8, "application/json"); + using HttpResponseMessage respondResponse = await s_sharedHttpClient.PostAsync(respondUri, respondContent); + + Assert.True(respondResponse.IsSuccessStatusCode, $"Respond request failed with status: {respondResponse.StatusCode}"); + string respondResponseText = await respondResponse.Content.ReadAsStringAsync(); + Assert.Contains("Response sent to workflow", respondResponseText); + this._outputHelper.WriteLine($"Respond response: {respondResponseText}"); + + // Step 4: Wait for the workflow to pause at the parallel BudgetApproval and ComplianceApproval RequestPorts + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'BudgetApproval'")); + return Task.FromResult(exists); + } + }, + message: "Workflow paused at BudgetApproval RequestPort", + timeout: s_orchestrationTimeout); + + // Step 5a: Send budget approval response + this._outputHelper.WriteLine("Sending BudgetApproval response..."); + + using HttpContent budgetContent = new StringContent( + """{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved by test."}}""", + Encoding.UTF8, "application/json"); + using HttpResponseMessage budgetResponse = await s_sharedHttpClient.PostAsync(respondUri, budgetContent); + + Assert.True(budgetResponse.IsSuccessStatusCode, $"BudgetApproval request failed with status: {budgetResponse.StatusCode}"); + this._outputHelper.WriteLine($"BudgetApproval response: {await budgetResponse.Content.ReadAsStringAsync()}"); + + // Step 5b: Send compliance approval response + this._outputHelper.WriteLine("Sending ComplianceApproval response..."); + + using HttpContent complianceContent = new StringContent( + """{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved by test."}}""", + Encoding.UTF8, "application/json"); + using HttpResponseMessage complianceResponse = await s_sharedHttpClient.PostAsync(respondUri, complianceContent); + + Assert.True(complianceResponse.IsSuccessStatusCode, $"ComplianceApproval request failed with status: {complianceResponse.StatusCode}"); + this._outputHelper.WriteLine($"ComplianceApproval response: {await complianceResponse.Content.ReadAsStringAsync()}"); + + // Step 6: Wait for the workflow to complete + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Workflow completed")); + return Task.FromResult(exists); + } + }, + message: "HITL workflow completed", + timeout: s_orchestrationTimeout); + + // Verify executor activities ran + lock (logs) + { + Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ManagerApproval'")), + "ManagerApproval external event receipt not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'BudgetApproval'")), + "BudgetApproval external event receipt not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ComplianceApproval'")), + "ComplianceApproval external event receipt not found in logs."); + } + }); + } + + [Fact] + public async Task ConcurrentWorkflowSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) => + { + // Start the ExpertReview workflow with a science question + const string RequestBody = "What is temperature?"; + using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain"); + + Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpertReview/run"); + this._outputHelper.WriteLine($"Starting ExpertReview workflow via POST request to {startUri}..."); + using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content); + + Assert.True(startResponse.IsSuccessStatusCode, $"ExpertReview request failed with status: {startResponse.StatusCode}"); + string startResponseText = await startResponse.Content.ReadAsStringAsync(); + Assert.Contains("ExpertReview", startResponseText); + this._outputHelper.WriteLine($"ExpertReview response: {startResponseText}"); + + // Wait for the ParseQuestion executor to run + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("[ParseQuestion]")); + return Task.FromResult(exists); + } + }, + message: "ParseQuestion executor ran", + timeout: s_orchestrationTimeout); + + // Wait for the Aggregator to complete (indicates fan-in from parallel agents) + await this.WaitForConditionAsync( + condition: () => + { + lock (logs) + { + bool exists = logs.Any(log => log.Message.Contains("Aggregation complete")); + return Task.FromResult(exists); + } + }, + message: "Aggregator completed with parallel agent responses", + timeout: s_orchestrationTimeout); + + // Verify the aggregator received responses from both AI agents + lock (logs) + { + Assert.True( + logs.Any(log => log.Message.Contains("AI agent responses")), + "Aggregator did not log receiving AI agent responses."); + } + }); + } + + private async Task StartSharedInfrastructureAsync() + { + // Start Azurite if it's not already running + if (!await this.IsAzuriteRunningAsync()) + { + await this.StartDockerContainerAsync( + containerName: "azurite", + image: "mcr.microsoft.com/azure-storage/azurite", + ports: ["-p", "10000:10000", "-p", "10001:10001", "-p", "10002:10002"]); + + await this.WaitForConditionAsync(this.IsAzuriteRunningAsync, "Azurite is running", TimeSpan.FromSeconds(30)); + } + + // Start DTS emulator if it's not already running + if (!await this.IsDtsEmulatorRunningAsync()) + { + await this.StartDockerContainerAsync( + containerName: "dts-emulator", + image: "mcr.microsoft.com/dts/dts-emulator:latest", + ports: ["-p", "8080:8080", "-p", "8082:8082"]); + + await this.WaitForConditionAsync( + condition: this.IsDtsEmulatorRunningAsync, + message: "DTS emulator is running", + timeout: TimeSpan.FromSeconds(30)); + } + } + + private async Task IsAzuriteRunningAsync() + { + this._outputHelper.WriteLine( + $"Checking if Azurite is running at http://localhost:{AzuritePort}/devstoreaccount1..."); + + try + { + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); + using HttpResponseMessage response = await s_sharedHttpClient.GetAsync( + requestUri: new Uri($"http://localhost:{AzuritePort}/devstoreaccount1?comp=list"), + cancellationToken: timeoutCts.Token); + if (response.Headers.TryGetValues( + "Server", + out IEnumerable? serverValues) && serverValues.Any(s => s.StartsWith("Azurite", StringComparison.OrdinalIgnoreCase))) + { + this._outputHelper.WriteLine($"Azurite is running, server: {string.Join(", ", serverValues)}"); + return true; + } + + this._outputHelper.WriteLine($"Azurite is not running. Status code: {response.StatusCode}"); + return false; + } + catch (HttpRequestException ex) + { + this._outputHelper.WriteLine($"Azurite is not running: {ex.Message}"); + return false; + } + } + + private async Task IsDtsEmulatorRunningAsync() + { + this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz..."); + + using HttpClient http2Client = new() + { + DefaultRequestVersion = new Version(2, 0), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + try + { + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30)); + using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token); + if (response.Content.Headers.ContentLength > 0) + { + string content = await response.Content.ReadAsStringAsync(timeoutCts.Token); + this._outputHelper.WriteLine($"DTS emulator health check response: {content}"); + } + + if (response.IsSuccessStatusCode) + { + this._outputHelper.WriteLine("DTS emulator is running"); + return true; + } + + this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}"); + return false; + } + catch (HttpRequestException ex) + { + this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}"); + return false; + } + } + + private async Task StartDockerContainerAsync(string containerName, string image, string[] ports) + { + await this.RunCommandAsync("docker", ["stop", containerName]); + await this.RunCommandAsync("docker", ["rm", containerName]); + + List args = ["run", "-d", "--name", containerName]; + args.AddRange(ports); + args.Add(image); + + this._outputHelper.WriteLine( + $"Starting new container: {containerName} with image: {image} and ports: {string.Join(", ", ports)}"); + await this.RunCommandAsync("docker", args.ToArray()); + this._outputHelper.WriteLine($"Container started: {containerName}"); + } + + private async Task WaitForConditionAsync(Func> condition, string message, TimeSpan timeout) + { + this._outputHelper.WriteLine($"Waiting for '{message}'..."); + + using CancellationTokenSource cancellationTokenSource = new(timeout); + while (true) + { + if (await condition()) + { + return; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationTokenSource.Token); + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + throw new TimeoutException($"Timeout waiting for '{message}'"); + } + } + } + + private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); + + private async Task RunSampleTestAsync(string samplePath, bool requiresOpenAI, Func, Task> testAction) + { + List logsContainer = []; + using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer, requiresOpenAI); + try + { + await this.WaitForAzureFunctionsAsync(); + await testAction(logsContainer); + } + finally + { + await this.StopProcessAsync(funcProcess); + } + } + + private Process StartFunctionApp(string samplePath, List logs, bool requiresOpenAI) + { + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}", + WorkingDirectory = samplePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + if (requiresOpenAI) + { + string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set."); + string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ?? + throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set."); + + this._outputHelper.WriteLine($"Using Azure OpenAI endpoint: {openAiEndpoint}, deployment: {openAiDeployment}"); + + startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint; + startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT"] = openAiDeployment; + } + + startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = + $"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None"; + startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true"; + + Process process = new() { StartInfo = startInfo }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + this._outputHelper.WriteLine($"[{startInfo.FileName}(err)]: {e.Data}"); + lock (logs) + { + logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data)); + } + } + }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + this._outputHelper.WriteLine($"[{startInfo.FileName}(out)]: {e.Data}"); + lock (logs) + { + logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data)); + } + } + }; + + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the function app"); + } + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + return process; + } + + private async Task WaitForAzureFunctionsAsync() + { + this._outputHelper.WriteLine( + $"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/..."); + await this.WaitForConditionAsync( + condition: async () => + { + try + { + using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/"); + using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request); + this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}"); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + return false; + } + }, + message: "Azure Functions Core Tools is ready", + timeout: s_functionsReadyTimeout); + } + + private async Task RunCommandAsync(string command, string[] args) + { + ProcessStartInfo startInfo = new() + { + FileName = command, + Arguments = string.Join(" ", args), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}"); + + using Process process = new() { StartInfo = startInfo }; + process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}"); + process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}"); + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the command"); + } + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1)); + await process.WaitForExitAsync(cancellationTokenSource.Token); + + this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}"); + } + + private async Task StopProcessAsync(Process process) + { + try + { + if (!process.HasExited) + { + this._outputHelper.WriteLine($"Killing process {process.ProcessName}#{process.Id}"); + process.Kill(entireProcessTree: true); + + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(timeoutCts.Token); + this._outputHelper.WriteLine($"Process exited: {process.Id}"); + } + } + catch (Exception ex) + { + this._outputHelper.WriteLine($"Failed to stop process: {ex.Message}"); + } + } + + private static string GetTargetFramework() + { + string filePath = new Uri(typeof(WorkflowSamplesValidation).Assembly.Location).LocalPath; + string directory = Path.GetDirectoryName(filePath)!; + string tfm = Path.GetFileName(directory); + if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + { + return tfm; + } + + throw new InvalidOperationException($"Unable to find target framework in path: {filePath}"); + } +} From cdb51e6a41a2ea6f353360a353943da8bcb18eb3 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 17 Mar 2026 11:00:04 +0100 Subject: [PATCH 18/25] Python: fix thread serialization for multi-turn tool calls (#4684) * Python: strip fc_id from loaded history * Move fc_id replay handling into Responses client Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary pytest asyncio marker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Responses integration test for fc_id replay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * removed old arg --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/_responses_client.py | 62 +++-- .../packages/core/tests/core/test_agents.py | 125 +++++++++- .../openai/test_openai_responses_client.py | 213 +++++++++++++++--- python/pyproject.toml | 29 +++ 4 files changed, 373 insertions(+), 56 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 2021eec603..0769c3f1f9 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -1032,24 +1032,27 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Returns: The prepared chat messages for a request. """ - call_id_to_id: dict[str, str] = {} - for message in chat_messages: - for content in message.contents: - if ( - content.type == "function_call" - and content.additional_properties - and "fc_id" in content.additional_properties - and content.additional_properties["fc_id"] - ): - call_id_to_id[content.call_id] = content.additional_properties["fc_id"] # type: ignore[attr-defined, index] - list_of_list = [self._prepare_message_for_openai(message, call_id_to_id) for message in chat_messages] + list_of_list = [self._prepare_message_for_openai(message) for message in chat_messages] # Flatten the list of lists into a single list return list(chain.from_iterable(list_of_list)) + @staticmethod + def _message_replays_provider_context(message: Message) -> bool: + """Return whether the message came from provider-attributed replay context. + + Responses ``fc_id`` values are response-scoped and only valid while replaying + the same live tool loop. Once a message comes back through a context provider + (for example, loaded session history), that message is historical input and + must not reuse the original response-scoped ``fc_id``. + """ + additional_properties = getattr(message, "additional_properties", None) + if not additional_properties: + return False + return "_attribution" in additional_properties + def _prepare_message_for_openai( self, message: Message, - call_id_to_id: dict[str, str], ) -> list[dict[str, Any]]: """Prepare a chat message for the OpenAI Responses API format.""" all_messages: list[dict[str, Any]] = [] @@ -1067,39 +1070,41 @@ class RawOpenAIResponsesClient( # type: ignore[misc] case "text_reasoning": if not has_function_call: continue # reasoning not followed by a function_call is invalid in input - reasoning = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type] + reasoning = self._prepare_content_for_openai(message.role, content, message=message) if reasoning: all_messages.append(reasoning) case "function_result": new_args: dict[str, Any] = {} - new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore[arg-type] + new_args.update(self._prepare_content_for_openai(message.role, content, message=message)) if new_args: all_messages.append(new_args) case "function_call": - function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type] + function_call = self._prepare_content_for_openai(message.role, content, message=message) if function_call: - all_messages.append(function_call) # type: ignore + all_messages.append(function_call) case "function_approval_response" | "function_approval_request": - prepared = self._prepare_content_for_openai(Role(message.role), content, call_id_to_id) + prepared = self._prepare_content_for_openai(message.role, content, message=message) if prepared: - all_messages.append(prepared) # type: ignore + all_messages.append(prepared) case _: - prepared_content = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore + prepared_content = self._prepare_content_for_openai(message.role, content, message=message) if prepared_content: if "content" not in args: args["content"] = [] - args["content"].append(prepared_content) # type: ignore + args["content"].append(prepared_content) # type: ignore[reportUnknownMemberType] if "content" in args or "tool_calls" in args: all_messages.append(args) return all_messages def _prepare_content_for_openai( self, - role: Role, + role: Role | str, content: Content, - call_id_to_id: dict[str, str], + *, + message: Message | None = None, ) -> dict[str, Any]: """Prepare content for the OpenAI Responses API format.""" + role = Role(role) match content.type: case "text": if role == "assistant": @@ -1174,8 +1179,15 @@ class RawOpenAIResponsesClient( # type: ignore[misc] if not content.call_id: logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'") return {} - # Use fc_id from additional_properties if available, otherwise fallback to call_id - fc_id = call_id_to_id.get(content.call_id, content.call_id) + fc_id = content.call_id + if ( + message is not None + and not self._message_replays_provider_context(message) + and content.additional_properties + ): + live_fc_id = content.additional_properties.get("fc_id") + if isinstance(live_fc_id, str) and live_fc_id: + fc_id = live_fc_id # OpenAI Responses API requires IDs to start with `fc_` if not fc_id.startswith("fc_"): fc_id = f"fc_{fc_id}" @@ -1221,7 +1233,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] if item.type == "text": output_parts.append({"type": "input_text", "text": item.text or ""}) else: - part = self._prepare_content_for_openai("user", item, call_id_to_id) # type: ignore[arg-type] + part = self._prepare_content_for_openai("user", item) if part: output_parts.append(part) if output_parts: diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 8e6faa37c4..cab55196f8 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -2,9 +2,10 @@ import contextlib import inspect +import json from collections.abc import AsyncIterable, MutableSequence from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest @@ -1943,6 +1944,128 @@ async def test_stores_by_default_with_store_false_in_default_options_injects_inm assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) +async def test_shared_local_storage_cross_provider_responses_history_does_not_leak_fc_id() -> None: + """Responses-specific replay metadata should stay local to Responses when session storage is shared.""" + from openai.types.chat.chat_completion import ChatCompletion, Choice + from openai.types.chat.chat_completion_message import ChatCompletionMessage + + from agent_framework._sessions import InMemoryHistoryProvider + from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient + + @tool(approval_mode="never_require") + def search_hotels(city: str) -> str: + return f"Found 3 hotels in {city}" + + responses_client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + responses_agent = Agent( + client=responses_client, + tools=[search_hotels], + default_options={"store": False}, + ) + session = responses_agent.create_session() + + responses_tool_call = MagicMock() + responses_tool_call.type = "function_call" + responses_tool_call.id = "fc_provider123" + responses_tool_call.call_id = "call_1" + responses_tool_call.name = "search_hotels" + responses_tool_call.arguments = '{"city": "Paris"}' + responses_tool_call.status = "completed" + + responses_first = MagicMock() + responses_first.output_parsed = None + responses_first.metadata = {} + responses_first.usage = None + responses_first.id = "resp_1" + responses_first.model = "test-model" + responses_first.created_at = 1000000000 + responses_first.status = "completed" + responses_first.finish_reason = "tool_calls" + responses_first.incomplete = None + responses_first.output = [responses_tool_call] + + responses_text_item = MagicMock() + responses_text_item.type = "message" + responses_text_content = MagicMock() + responses_text_content.type = "output_text" + responses_text_content.text = "Hotel Lutetia is the cheapest option." + responses_text_item.content = [responses_text_content] + + responses_second = MagicMock() + responses_second.output_parsed = None + responses_second.metadata = {} + responses_second.usage = None + responses_second.id = "resp_2" + responses_second.model = "test-model" + responses_second.created_at = 1000000001 + responses_second.status = "completed" + responses_second.finish_reason = "stop" + responses_second.incomplete = None + responses_second.output = [responses_text_item] + + with patch.object( + responses_client.client.responses, + "create", + side_effect=[responses_first, responses_second], + ) as mock_responses_create: + responses_result = await responses_agent.run("Find me a hotel in Paris", session=session) + + assert responses_result.text == "Hotel Lutetia is the cheapest option." + assert any(isinstance(provider, InMemoryHistoryProvider) for provider in responses_agent.context_providers) + + shared_messages = session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"] + shared_function_call = next( + content for message in shared_messages for content in message.contents if content.type == "function_call" + ) + assert shared_function_call.additional_properties is not None + assert shared_function_call.additional_properties.get("fc_id") == "fc_provider123" + + responses_replay_input = mock_responses_create.call_args_list[1].kwargs["input"] + responses_replay_call = next(item for item in responses_replay_input if item.get("type") == "function_call") + assert responses_replay_call["id"] == "fc_provider123" + + chat_client = OpenAIChatClient(model_id="test-model", api_key="test-key") + chat_agent = Agent(client=chat_client) + + chat_response = ChatCompletion( + id="chatcmpl-test", + object="chat.completion", + created=1234567890, + model="gpt-4o-mini", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content="The cheapest option is still Hotel Lutetia."), + finish_reason="stop", + ) + ], + ) + + with patch.object( + chat_client.client.chat.completions, + "create", + new=AsyncMock(return_value=chat_response), + ) as mock_chat_create: + chat_result = await chat_agent.run("Which option is cheapest?", session=session) + + assert chat_result.text == "The cheapest option is still Hotel Lutetia." + + chat_request_messages = mock_chat_create.call_args.kwargs["messages"] + assistant_tool_call_message = next( + message for message in chat_request_messages if message.get("role") == "assistant" and message.get("tool_calls") + ) + assert assistant_tool_call_message["tool_calls"][0]["id"] == "call_1" + assert assistant_tool_call_message["tool_calls"][0]["function"]["name"] == "search_hotels" + + tool_result_message = next( + message + for message in chat_request_messages + if message.get("role") == "tool" and message.get("tool_call_id") == "call_1" + ) + assert tool_result_message["content"] == "Found 3 hotels in Paris" + assert "fc_provider123" not in json.dumps(chat_request_messages) + + # region as_tool user_input_request propagation diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 9506c8ec47..6a2c9f5173 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -28,6 +28,7 @@ from pydantic import BaseModel from pytest import param from agent_framework import ( + Agent, ChatOptions, ChatResponse, ChatResponseUpdate, @@ -37,6 +38,11 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) +from agent_framework._sessions import ( + AgentSession, + InMemoryHistoryProvider, + SessionContext, +) from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, @@ -1050,7 +1056,7 @@ def test_prepare_content_for_opentool_approval_response() -> None: function_call=function_call, ) - result = client._prepare_content_for_openai("assistant", approval_response, {}) + result = client._prepare_content_for_openai("assistant", approval_response) assert result["type"] == "mcp_approval_response" assert result["approval_request_id"] == "approval_001" @@ -1067,7 +1073,7 @@ def test_prepare_content_for_openai_error_content() -> None: error_details="Invalid parameter", ) - result = client._prepare_content_for_openai("assistant", error_content, {}) + result = client._prepare_content_for_openai("assistant", error_content) # ErrorContent should return empty dict (logged but not sent) assert result == {} @@ -1085,7 +1091,7 @@ def test_prepare_content_for_openai_usage_content() -> None: } ) - result = client._prepare_content_for_openai("assistant", usage_content, {}) + result = client._prepare_content_for_openai("assistant", usage_content) # UsageContent should return empty dict (logged but not sent) assert result == {} @@ -1099,7 +1105,7 @@ def test_prepare_content_for_openai_hosted_vector_store_content() -> None: vector_store_id="vs_123", ) - result = client._prepare_content_for_openai("assistant", vector_store_content, {}) + result = client._prepare_content_for_openai("assistant", vector_store_content) # HostedVectorStoreContent should return empty dict (logged but not sent) assert result == {} @@ -1111,8 +1117,8 @@ def test_prepare_content_for_openai_text_uses_role_specific_type() -> None: text_content = Content.from_text(text="hello") - user_result = client._prepare_content_for_openai("user", text_content, {}) - assistant_result = client._prepare_content_for_openai("assistant", text_content, {}) + user_result = client._prepare_content_for_openai("user", text_content) + assistant_result = client._prepare_content_for_openai("assistant", text_content) assert user_result["type"] == "input_text" assert assistant_result["type"] == "output_text" @@ -1234,9 +1240,8 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None: ) message = Message(role="user", contents=[approval_response]) - call_id_to_id: dict[str, str] = {} - result = client._prepare_message_for_openai(message, call_id_to_id) + result = client._prepare_message_for_openai(message) # FunctionApprovalResponseContent is added directly, not nested in args with role assert len(result) == 1 @@ -1267,9 +1272,8 @@ def test_prepare_message_for_openai_includes_reasoning_with_function_call() -> N ) message = Message(role="assistant", contents=[reasoning, function_call]) - call_id_to_id: dict[str, str] = {} - result = client._prepare_message_for_openai(message, call_id_to_id) + result = client._prepare_message_for_openai(message) # Both reasoning and function_call should be present as top-level items types = [item["type"] for item in result] @@ -1355,9 +1359,8 @@ def test_prepare_message_for_openai_filters_error_content() -> None: ) message = Message(role="assistant", contents=[error_content]) - call_id_to_id: dict[str, str] = {} - result = client._prepare_message_for_openai(message, call_id_to_id) + result = client._prepare_message_for_openai(message) # Message should be empty since ErrorContent is filtered out assert len(result) == 0 @@ -1376,9 +1379,8 @@ def test_chat_message_with_usage_content() -> None: ) message = Message(role="assistant", contents=[usage_content]) - call_id_to_id: dict[str, str] = {} - result = client._prepare_message_for_openai(message, call_id_to_id) + result = client._prepare_message_for_openai(message) # Message should be empty since UsageContent is filtered out assert len(result) == 0 @@ -1394,8 +1396,7 @@ def test_hosted_file_content_preparation() -> None: name="document.pdf", ) - result = client._prepare_content_for_openai("user", hosted_file, {}) - + result = client._prepare_content_for_openai("user", hosted_file) assert result["type"] == "input_file" assert result["file_id"] == "file_abc123" @@ -1417,7 +1418,7 @@ def test_function_approval_response_with_mcp_tool_call() -> None: function_call=mcp_call, ) - result = client._prepare_content_for_openai("assistant", approval_response, {}) + result = client._prepare_content_for_openai("assistant", approval_response) assert result["type"] == "mcp_approval_response" assert result["approval_request_id"] == "approval_mcp_001" @@ -2259,7 +2260,7 @@ def test_prepare_content_for_openai_image_content() -> None: media_type="image/jpeg", additional_properties={"detail": "high", "file_id": "file_123"}, ) - result = client._prepare_content_for_openai("user", image_content_with_detail, {}) # type: ignore + result = client._prepare_content_for_openai("user", image_content_with_detail) assert result["type"] == "input_image" assert result["image_url"] == "https://example.com/image.jpg" assert result["detail"] == "high" @@ -2267,7 +2268,7 @@ def test_prepare_content_for_openai_image_content() -> None: # Test image content without additional properties (defaults) image_content_basic = Content.from_uri(uri="https://example.com/basic.png", media_type="image/png") - result = client._prepare_content_for_openai("user", image_content_basic, {}) # type: ignore + result = client._prepare_content_for_openai("user", image_content_basic) assert result["type"] == "input_image" assert result["detail"] == "auto" assert result["file_id"] is None @@ -2279,14 +2280,14 @@ def test_prepare_content_for_openai_audio_content() -> None: # Test WAV audio content wav_content = Content.from_uri(uri="data:audio/wav;base64,abc123", media_type="audio/wav") - result = client._prepare_content_for_openai("user", wav_content, {}) # type: ignore + result = client._prepare_content_for_openai("user", wav_content) assert result["type"] == "input_audio" assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123" assert result["input_audio"]["format"] == "wav" # Test MP3 audio content mp3_content = Content.from_uri(uri="data:audio/mp3;base64,def456", media_type="audio/mp3") - result = client._prepare_content_for_openai("user", mp3_content, {}) # type: ignore + result = client._prepare_content_for_openai("user", mp3_content) assert result["type"] == "input_audio" assert result["input_audio"]["format"] == "mp3" @@ -2297,12 +2298,12 @@ def test_prepare_content_for_openai_unsupported_content() -> None: # Test unsupported audio format unsupported_audio = Content.from_uri(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg") - result = client._prepare_content_for_openai("user", unsupported_audio, {}) # type: ignore + result = client._prepare_content_for_openai("user", unsupported_audio) assert result == {} # Test non-media content text_uri_content = Content.from_uri(uri="https://example.com/document.txt", media_type="text/plain") - result = client._prepare_content_for_openai("user", text_uri_content, {}) # type: ignore + result = client._prepare_content_for_openai("user", text_uri_content) assert result == {} @@ -2316,7 +2317,7 @@ def test_prepare_content_for_openai_function_result_with_rich_items() -> None: result=[Content.from_text("Result text"), image_content], ) - result = client._prepare_content_for_openai("user", content, {}) # type: ignore + result = client._prepare_content_for_openai("user", content) assert result["type"] == "function_call_output" assert result["call_id"] == "call_rich" @@ -2338,7 +2339,7 @@ def test_prepare_content_for_openai_function_result_without_items() -> None: result="Simple result", ) - result = client._prepare_content_for_openai("user", content, {}) # type: ignore + result = client._prepare_content_for_openai("user", content) assert result["type"] == "function_call_output" assert result["call_id"] == "call_plain" @@ -2362,7 +2363,7 @@ def test_parse_chunk_from_openai_code_interpreter() -> None: mock_item_image.code = None mock_event_image.item = mock_item_image - result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) # type: ignore + result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) assert len(result.contents) == 1 assert result.contents[0].type == "code_interpreter_tool_result" assert result.contents[0].outputs @@ -2385,7 +2386,7 @@ def test_parse_chunk_from_openai_code_interpreter_delta() -> None: mock_delta_event.call_id = None # Ensure fallback to item_id mock_delta_event.id = None - result = client._parse_chunk_from_openai(mock_delta_event, chat_options, function_call_ids) # type: ignore + result = client._parse_chunk_from_openai(mock_delta_event, chat_options, function_call_ids) assert len(result.contents) == 1 assert result.contents[0].type == "code_interpreter_tool_call" assert result.contents[0].call_id == "ci_123" @@ -2414,7 +2415,7 @@ def test_parse_chunk_from_openai_code_interpreter_done() -> None: mock_done_event.call_id = None # Ensure fallback to item_id mock_done_event.id = None - result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids) # type: ignore + result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids) assert len(result.contents) == 1 assert result.contents[0].type == "code_interpreter_tool_call" assert result.contents[0].call_id == "ci_456" @@ -2443,7 +2444,7 @@ def test_parse_chunk_from_openai_reasoning() -> None: mock_item_reasoning.summary = ["Problem analysis summary"] mock_event_reasoning.item = mock_item_reasoning - result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids) # type: ignore + result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids) assert len(result.contents) == 1 assert result.contents[0].type == "text_reasoning" assert result.contents[0].text == "Analyzing the problem step by step..." @@ -2465,7 +2466,7 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None: "encrypted_content": "secure_data_456", }, ) - result = client._prepare_content_for_openai("assistant", comprehensive_reasoning, {}) # type: ignore + result = client._prepare_content_for_openai("assistant", comprehensive_reasoning) assert result["type"] == "reasoning" assert result["id"] == "rs_comprehensive" assert result["summary"][0]["text"] == "Comprehensive reasoning summary" @@ -3241,6 +3242,53 @@ async def test_integration_tool_rich_content_image() -> None: assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" +@pytest.mark.timeout(300) +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_openai_integration_tests_disabled +async def test_integration_agent_replays_local_tool_history_without_stale_fc_id() -> None: + """Integration test: persisted local Responses tool history can be replayed on a later turn.""" + hotel_code = "HOTEL-PERSIST-4672" + + @tool(name="search_hotels", approval_mode="never_require") + async def search_hotels(city: Annotated[str, "The city to search for hotels in"]) -> str: + return f"The only hotel option in {city} is {hotel_code}." + + client = OpenAIResponsesClient() + client.function_invocation_configuration["max_iterations"] = 2 + + agent = Agent( + client=client, + tools=[search_hotels], + default_options={"store": False}, + ) + session = agent.create_session() + + first_response = await agent.run( + "Call the search_hotels tool for Paris and answer with the hotel code you found.", + session=session, + options={"tool_choice": {"mode": "required", "required_function_name": "search_hotels"}}, + ) + assert first_response.text is not None + assert hotel_code in first_response.text + + shared_messages = session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"] + shared_function_call = next( + content for message in shared_messages for content in message.contents if content.type == "function_call" + ) + assert shared_function_call.additional_properties is not None + assert isinstance(shared_function_call.additional_properties.get("fc_id"), str) + assert shared_function_call.additional_properties["fc_id"] + + second_response = await agent.run( + "What hotel code did you already find for Paris? Answer with the exact code only.", + session=session, + options={"tool_choice": "none"}, + ) + assert second_response.text is not None + assert hotel_code in second_response.text + + def test_continuation_token_json_serializable() -> None: """Test that OpenAIContinuationToken is a plain dict and JSON-serializable.""" from agent_framework.openai import OpenAIContinuationToken @@ -3542,6 +3590,111 @@ def test_parse_response_from_openai_function_call_includes_status() -> None: assert function_call.raw_representation is mock_function_call_item +async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_from_history() -> None: + """Loaded history must not replay provider-ephemeral Responses function call IDs.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + provider = InMemoryHistoryProvider() + + session = AgentSession(session_id="thread-1") + session.state[provider.source_id] = { + "messages": [ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="search_hotels", + arguments='{"city": "Paris"}', + additional_properties={"fc_id": "fc_provider123", "status": "completed"}, + ), + ], + ), + Message( + role="tool", + contents=[ + Content.from_function_result( + call_id="call_1", + result="Found 3 hotels in Paris", + ), + ], + ), + ] + } + + next_turn_input = Message(role="user", contents=[Content.from_text(text="Book the cheapest one")]) + + live_result = client._prepare_messages_for_openai([*session.state[provider.source_id]["messages"], next_turn_input]) + live_function_call = next(item for item in live_result if item.get("type") == "function_call") + assert live_function_call["id"] == "fc_provider123" + + context = SessionContext(session_id=session.session_id, input_messages=[next_turn_input]) + await provider.before_run( + agent=None, + session=session, + context=context, + state=session.state.setdefault(provider.source_id, {}), + ) # type: ignore[arg-type] + + loaded_result = client._prepare_messages_for_openai( + context.get_messages(sources={provider.source_id}, include_input=True) + ) + loaded_function_call = next(item for item in loaded_result if item.get("type") == "function_call") + assert loaded_function_call["id"] == "fc_call_1" + + stored_function_call = session.state[provider.source_id]["messages"][0].contents[0] + assert stored_function_call.additional_properties is not None + assert stored_function_call.additional_properties.get("fc_id") == "fc_provider123" + + restored = AgentSession.from_dict(json.loads(json.dumps(session.to_dict()))) + restored_context = SessionContext(session_id=restored.session_id, input_messages=[next_turn_input]) + await provider.before_run( + agent=None, + session=restored, + context=restored_context, + state=restored.state.setdefault(provider.source_id, {}), + ) # type: ignore[arg-type] + + restored_result = client._prepare_messages_for_openai( + restored_context.get_messages(sources={provider.source_id}, include_input=True) + ) + restored_function_call = next(item for item in restored_result if item.get("type") == "function_call") + assert restored_function_call["id"] == "fc_call_1" + + +def test_prepare_messages_for_openai_keeps_live_fc_id_separate_from_replayed_history() -> None: + """Replayed history must not borrow a live Responses function call ID with the same call_id.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + history_message = Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="search_hotels", + arguments='{"city": "Paris"}', + additional_properties={"fc_id": "fc_history123"}, + ) + ], + additional_properties={"_attribution": {"source_id": "history", "source_type": "InMemoryHistoryProvider"}}, + ) + live_message = Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="search_hotels", + arguments='{"city": "London"}', + additional_properties={"fc_id": "fc_live123"}, + ) + ], + ) + + result = client._prepare_messages_for_openai([history_message, live_message]) + + function_calls = [item for item in result if item.get("type") == "function_call"] + assert [item["id"] for item in function_calls] == ["fc_call_1", "fc_live123"] + + def test_prepare_messages_for_openai_filters_empty_fc_id() -> None: """Test _prepare_messages_for_openai correctly filters empty fc_id values from call_id_to_id mapping.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") diff --git a/python/pyproject.toml b/python/pyproject.toml index 5a413fef67..0e53e072d1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -174,6 +174,35 @@ typeCheckingMode = "strict" reportUnnecessaryIsInstance = false reportMissingTypeStubs = false reportUnnecessaryCast = "error" +# Tests intentionally probe internal implementation details. +executionEnvironments = [ + { root = "packages/a2a/tests", reportPrivateUsage = "none" }, + { root = "packages/ag-ui/tests", reportPrivateUsage = "none" }, + { root = "packages/anthropic/tests", reportPrivateUsage = "none" }, + { root = "packages/azure-ai-search/tests", reportPrivateUsage = "none" }, + { root = "packages/azure-ai/tests", reportPrivateUsage = "none" }, + { root = "packages/azure-cosmos/tests", reportPrivateUsage = "none" }, + { root = "packages/azurefunctions/tests", reportPrivateUsage = "none" }, + { root = "packages/bedrock/tests", reportPrivateUsage = "none" }, + { root = "packages/chatkit/tests", reportPrivateUsage = "none" }, + { root = "packages/claude/tests", reportPrivateUsage = "none" }, + { root = "packages/copilotstudio/tests", reportPrivateUsage = "none" }, + { root = "packages/core/tests", reportPrivateUsage = "none" }, + { root = "packages/declarative/tests", reportPrivateUsage = "none" }, + { root = "packages/devui/tests", reportPrivateUsage = "none" }, + { root = "packages/durabletask/tests", reportPrivateUsage = "none" }, + { root = "packages/foundry_local/tests", reportPrivateUsage = "none" }, + { root = "packages/github_copilot/tests", reportPrivateUsage = "none" }, + { root = "packages/lab/gaia/tests", reportPrivateUsage = "none" }, + { root = "packages/lab/lightning/tests", reportPrivateUsage = "none" }, + { root = "packages/lab/tau2/tests", reportPrivateUsage = "none" }, + { root = "packages/mem0/tests", reportPrivateUsage = "none" }, + { root = "packages/ollama/tests", reportPrivateUsage = "none" }, + { root = "packages/orchestrations/tests", reportPrivateUsage = "none" }, + { root = "packages/purview/tests", reportPrivateUsage = "none" }, + { root = "packages/redis/tests", reportPrivateUsage = "none" }, + { root = "tests", reportPrivateUsage = "none" }, +] [tool.mypy] plugins = ['pydantic.mypy'] From 94af83680ee60f1ac76aeb1307c0ba72a662ca40 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:44:44 +0900 Subject: [PATCH 19/25] Python: Fix RUN_FINISHED.interrupt to accumulate all interrupts when multiple tools need approval (#4717) * Fix flow.interrupts overwrite when multiple tools need approval (#4590) Change flow.interrupts assignment to append so that all interrupt entries accumulate when multiple tools require approval in a single turn. Both _run_common.py and _agent_run.py used assignment (=) which caused each new interrupt to overwrite the previous one. Switching to append() ensures RUN_FINISHED.interrupt contains all pending approvals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add test for streaming path with multiple confirm_changes interrupts (#4590) Add integration test exercising run_agent_stream with multiple predictive tool calls requiring confirmation. Verifies that flow.interrupts.append() correctly accumulates all interrupt entries and they appear in the RUN_FINISHED event. Also confirms FlowState already declares interrupts field with default_factory=list, addressing the AttributeError concern from review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 4 +- .../agent_framework_ag_ui/_run_common.py | 4 +- python/packages/ag-ui/tests/ag_ui/test_run.py | 96 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index c1f096a0b0..f0e70e46b3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -1015,7 +1015,7 @@ async def run_agent_stream( flow.tool_calls_by_id[confirm_id] = confirm_entry flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event flow.waiting_for_approval = True - flow.interrupts = [ + flow.interrupts.append( { "id": str(confirm_id), "value": { @@ -1027,7 +1027,7 @@ async def run_agent_stream( }, }, } - ] + ) # Close any open message if flow.message_id: diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index d8cf236add..cde338cbc7 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -320,7 +320,7 @@ def _emit_approval_request( ) interrupt_id = func_call_id or content.id if interrupt_id: - flow.interrupts = [ + flow.interrupts.append( { "id": str(interrupt_id), "value": { @@ -332,7 +332,7 @@ def _emit_approval_request( }, }, } - ] + ) if require_confirmation: confirm_id = generate_event_id() diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index e0771e1b7e..5a0cd1605c 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -538,6 +538,27 @@ def test_emit_approval_request_populates_interrupt_metadata(): assert flow.interrupts[0]["value"]["type"] == "function_approval_request" +def test_emit_approval_request_accumulates_multiple_interrupts(): + """Multiple approval requests in the same turn should accumulate in flow.interrupts.""" + flow = FlowState(message_id="msg-1") + + for i in range(1, 4): + function_call = Content.from_function_call( + call_id=f"call_{i}", + name=f"tool_{i}", + arguments={"arg": f"value_{i}"}, + ) + approval_content = Content.from_function_approval_request( + id=f"approval_{i}", + function_call=function_call, + ) + _emit_approval_request(approval_content, flow) + + assert len(flow.interrupts) == 3 + interrupt_ids = {intr["id"] for intr in flow.interrupts} + assert interrupt_ids == {"call_1", "call_2", "call_3"} + + def test_resume_to_tool_messages_from_interrupts_payload(): """Resume payload interrupt responses map to tool messages.""" resume = { @@ -874,6 +895,81 @@ class TestTextMessageEventBalancing: assert len(end_events) == 2 +async def test_run_agent_stream_accumulates_multiple_confirm_interrupts(): + """Multiple predictive tool calls in a single streaming run should accumulate interrupts. + + This exercises the confirm_changes path in run_agent_stream (_agent_run.py), + ensuring that flow.interrupts.append() works correctly for multiple tool calls + and all interrupts appear in the RUN_FINISHED event. + """ + import json + + from conftest import StubAgent + + from agent_framework_ag_ui import AgentFrameworkAgent + + predict_config = { + "tasks": {"tool": "generate_tasks", "tool_argument": "steps"}, + "notes": {"tool": "generate_notes", "tool_argument": "items"}, + } + state_schema = { + "tasks": {"type": "array", "items": {"type": "object"}}, + "notes": {"type": "array", "items": {"type": "object"}}, + } + + updates = [ + AgentResponseUpdate( + contents=[ + Content.from_function_call( + name="generate_tasks", + call_id="call-tasks", + arguments=json.dumps({"steps": [{"description": "Task 1"}]}), + ), + Content.from_function_call( + name="generate_notes", + call_id="call-notes", + arguments=json.dumps({"items": [{"description": "Note 1"}]}), + ), + ], + role="assistant", + ), + ] + + stub = StubAgent(updates=updates) + agent = AgentFrameworkAgent( + agent=stub, + state_schema=state_schema, + predict_state_config=predict_config, + require_confirmation=True, + ) + + payload = { + "thread_id": "thread-multi", + "run_id": "run-multi", + "messages": [{"role": "user", "content": "Generate tasks and notes"}], + "state": {"tasks": [], "notes": []}, + } + + events = [event async for event in agent.run(payload)] + + # Find RUN_FINISHED event and verify multiple interrupts + finished_events = [ + e + for e in events + if getattr(e, "type", None) == "RUN_FINISHED" + or getattr(getattr(e, "type", None), "value", None) == "RUN_FINISHED" + ] + assert finished_events, f"Expected RUN_FINISHED event. Types: {[getattr(e, 'type', None) for e in events]}" + finished = finished_events[-1] + interrupt = getattr(finished, "interrupt", None) + assert interrupt is not None, "Expected interrupt metadata in RUN_FINISHED" + assert len(interrupt) == 2, f"Expected 2 interrupts (one per tool), got {len(interrupt)}" + + # Verify both tool calls are represented in interrupt metadata + interrupt_tool_names = {i["value"]["function_call"]["name"] for i in interrupt} + assert interrupt_tool_names == {"generate_tasks", "generate_notes"} + + def test_emit_oauth_consent_request(): """Test that oauth_consent_request content emits a CustomEvent.""" content = Content.from_oauth_consent_request( From 21af304c7d887fc5dbd9129dc6eabc35b09464cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:04:07 -0700 Subject: [PATCH 20/25] Bump actions/setup-dotnet from 5.1.0 to 5.2.0 (#4541) Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.1.0...v5.2.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 4 ++-- .github/workflows/dotnet-integration-tests.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 3bdb43dabf..e9a1790d3a 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -85,7 +85,7 @@ jobs: workflow-samples - name: Setup dotnet - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json - name: Build dotnet solutions @@ -165,7 +165,7 @@ jobs: echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV - name: Setup dotnet - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 029ec5151d..15c2a16712 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -50,7 +50,7 @@ jobs: echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV - name: Setup dotnet - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json From 6dbb0a5bb4845f40408ad01afeb490e8802fea1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:04:20 -0700 Subject: [PATCH 21/25] Bump danielpalme/ReportGenerator-GitHub-Action from 5.5.1 to 5.5.3 (#4542) Bumps [danielpalme/ReportGenerator-GitHub-Action](https://github.com/danielpalme/reportgenerator-github-action) from 5.5.1 to 5.5.3. - [Release notes](https://github.com/danielpalme/reportgenerator-github-action/releases) - [Commits](https://github.com/danielpalme/reportgenerator-github-action/compare/5.5.1...5.5.3) --- updated-dependencies: - dependency-name: danielpalme/ReportGenerator-GitHub-Action dependency-version: 5.5.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index e9a1790d3a..ad95978ec2 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -281,7 +281,7 @@ jobs: # Generate test reports and check coverage - name: Generate test reports if: matrix.targetFramework == env.COVERAGE_FRAMEWORK - uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1 + uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3 with: reports: "./TestResults/Coverage/**/*.cobertura.xml" targetdir: "./TestResults/Reports" From 6af0511e2be154ef3234d3193b2e28c7c3172f5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:04:37 -0700 Subject: [PATCH 22/25] Bump MishaKav/pytest-coverage-comment from 1.2.0 to 1.6.0 (#4543) Bumps [MishaKav/pytest-coverage-comment](https://github.com/mishakav/pytest-coverage-comment) from 1.2.0 to 1.6.0. - [Release notes](https://github.com/mishakav/pytest-coverage-comment/releases) - [Changelog](https://github.com/MishaKav/pytest-coverage-comment/blob/main/CHANGELOG.md) - [Commits](https://github.com/mishakav/pytest-coverage-comment/compare/v1.2.0...v1.6.0) --- updated-dependencies: - dependency-name: MishaKav/pytest-coverage-comment dependency-version: 1.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-test-coverage-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml index 92e13f9168..f5f5f8eb03 100644 --- a/.github/workflows/python-test-coverage-report.yml +++ b/.github/workflows/python-test-coverage-report.yml @@ -46,7 +46,7 @@ jobs: echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV" - name: Pytest coverage comment id: coverageComment - uses: MishaKav/pytest-coverage-comment@v1.2.0 + uses: MishaKav/pytest-coverage-comment@v1.6.0 with: github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} issue-number: ${{ env.PR_NUMBER }} From 008fe23585a7226e98a53d0a6c8ab3e7a3468a75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:05:55 +0000 Subject: [PATCH 23/25] Bump actions/upload-artifact from 4 to 7 (#4373) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 2 +- .../python-dependency-range-validation.yml | 2 +- .github/workflows/python-sample-validation.yml | 14 +++++++------- .github/workflows/python-test-coverage.yml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index ad95978ec2..a47d09ff7d 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -289,7 +289,7 @@ jobs: - name: Upload coverage report artifact if: matrix.targetFramework == env.COVERAGE_FRAMEWORK - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name path: ./TestResults/Reports # Directory containing files to upload diff --git a/.github/workflows/python-dependency-range-validation.yml b/.github/workflows/python-dependency-range-validation.yml index 2f01552796..2e33693aa2 100644 --- a/.github/workflows/python-dependency-range-validation.yml +++ b/.github/workflows/python-dependency-range-validation.yml @@ -44,7 +44,7 @@ jobs: - name: Upload dependency range report # Always publish the report so failures are inspectable even when validation fails. if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dependency-range-results path: python/scripts/dependencies/dependency-range-results.json diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml index 5f36af65cc..4a14e6b41b 100644 --- a/.github/workflows/python-sample-validation.yml +++ b/.github/workflows/python-sample-validation.yml @@ -46,7 +46,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-01-get-started @@ -89,7 +89,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-02-agents @@ -126,7 +126,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-03-workflows @@ -165,7 +165,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-04-hosting @@ -209,7 +209,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-05-end-to-end @@ -249,7 +249,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-autogen-migration @@ -295,7 +295,7 @@ jobs: cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration - name: Upload validation report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: validation-report-semantic-kernel-migration diff --git a/.github/workflows/python-test-coverage.yml b/.github/workflows/python-test-coverage.yml index 7563504b69..d93e62062f 100644 --- a/.github/workflows/python-test-coverage.yml +++ b/.github/workflows/python-test-coverage.yml @@ -42,7 +42,7 @@ jobs: - name: Check coverage threshold run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }} - name: Upload coverage report - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: path: | python/python-coverage.xml From 1e6f8909ec4a8fcf199c5aead51dacb73d29b8d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:06:07 -0700 Subject: [PATCH 24/25] Bump pyjwt from 2.11.0 to 2.12.0 in /python (#4699) Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.11.0 to 2.12.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0) --- updated-dependencies: - dependency-name: pyjwt dependency-version: 2.12.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index a21d16ed54..992155f23f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -5047,11 +5047,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, ] [package.optional-dependencies] From 7c85f98c27ddef4fda9218a4a49a40a9ca6e13d0 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Tue, 17 Mar 2026 13:20:14 -0700 Subject: [PATCH 25/25] .NET: Align sample build configuration with test runner in CI (#4735) * Run azure functions integration tests in release mode. * Use debug when in debug build. --- .../SamplesValidation.cs | 10 ++++++++-- .../WorkflowSamplesValidation.cs | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index bd88c55cb8..c416fb6a2a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -21,6 +21,12 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi private const string RedisPort = "6379"; private static readonly string s_dotnetTargetFramework = GetTargetFramework(); + +#if DEBUG + private const string BuildConfiguration = "Debug"; +#else + private const string BuildConfiguration = "Release"; +#endif private static readonly HttpClient s_sharedHttpClient = new(); private static readonly IConfiguration s_configuration = new ConfigurationBuilder() @@ -825,7 +831,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi ProcessStartInfo buildInfo = new() { FileName = "dotnet", - Arguments = $"build -f {s_dotnetTargetFramework}", + Arguments = $"build -f {s_dotnetTargetFramework} -c {BuildConfiguration}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true, @@ -855,7 +861,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run --no-build -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}", + Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index d5ea083894..efb02b1aff 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -20,6 +20,12 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : private const string DtsPort = "8080"; private static readonly string s_dotnetTargetFramework = GetTargetFramework(); + +#if DEBUG + private const string BuildConfiguration = "Debug"; +#else + private const string BuildConfiguration = "Release"; +#endif private static readonly HttpClient s_sharedHttpClient = new(); private static readonly IConfiguration s_configuration = new ConfigurationBuilder() @@ -437,7 +443,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}", + Arguments = $"run -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true,