From b68b88dc2a746740828aa67f5f73fb0637cc9e99 Mon Sep 17 00:00:00 2001 From: Giraffe0129 <3012074063@qq.com> Date: Mon, 2 Nov 2026 18:51:30 +0800 Subject: [PATCH 1/3] fix: restore request_info events whose request or response type is NoneType --- .../_workflows/_typing_utils.py | 16 ++++++++++- .../test_request_info_event_rehydrate.py | 27 +++++++++++++++++++ .../core/tests/workflow/test_typing_utils.py | 5 +++- .../tests/workflow/test_workflow_agent.py | 24 +++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index ad41201d04d..1570fa45ef3 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -399,6 +399,13 @@ def _coerce_dict_to_dataclass(data: dict[str, Any], target_cls: type[Any]) -> An return data +# ``type(None)`` reports ``builtins`` as its module and ``NoneType`` as its qualified name, but +# ``builtins`` never binds that name: the class is only reachable as ``types.NoneType`` (3.10+) +# or ``type(None)``. The name that ``serialize_type`` produces therefore has to come from this +# table instead of from the module namespace. +_SERIALIZED_TYPE_ALIASES: Mapping[str, type[Any]] = {"builtins.NoneType": type(None)} + + def serialize_type(t: type) -> str: """Serialize a type to a string. @@ -416,7 +423,8 @@ def deserialize_type( ) -> type: """Deserialize a serialized type string. - Resolution is limited to exact caller-supplied types or types already present + Resolution is limited to exact caller-supplied types, a fixed alias table for builtin + names that their module never binds (``builtins.NoneType``), or types already present in loaded module namespaces. This function never imports a module selected by the serialized value. @@ -427,6 +435,7 @@ def deserialize_type( For example, deserialize_type("builtins.int") => int + deserialize_type("builtins.NoneType") => type(None) """ if allowed_types is not None and serialized_type_string in allowed_types: resolved = allowed_types[serialized_type_string] @@ -436,6 +445,11 @@ def deserialize_type( raise ValueError(f"allowed_types entry {serialized_type_string!r} does not match the supplied type.") return resolved + # Types whose serialized name is not an attribute of their module. + alias = _SERIALIZED_TYPE_ALIASES.get(serialized_type_string) + if alias is not None: + return alias + module_name, _, type_name = serialized_type_string.rpartition(".") module = sys.modules.get(module_name) if not isinstance(module, ModuleType): diff --git a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py index 1be0a75f58e..449ee736ace 100644 --- a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py +++ b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path +from typing import Any import pytest @@ -51,6 +52,32 @@ class TimedApproval: issued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) +@pytest.mark.parametrize( + ("request_data", "response_type"), + [(1, int), (None, str), ("payload", type(None))], +) +def test_workflow_event_from_dict_roundtrips_none_type(request_data: Any, response_type: type[Any]) -> None: + """A request whose data or response is ``None`` survives a to_dict/from_dict round trip. + + ``type(None)`` serializes as ``builtins.NoneType``, a name the ``builtins`` module does not + bind, so reconstructing the event used to raise AttributeError. + """ + event = WorkflowEvent.request_info( + request_id="request-123", + source_executor_id="review_gateway", + request_data=request_data, + response_type=response_type, + ) + + restored = WorkflowEvent.from_dict(event.to_dict()) + + assert restored.request_id == "request-123" + assert restored.source_executor_id == "review_gateway" + assert restored.data == request_data + assert restored.request_type is type(request_data) + assert restored.response_type is response_type + + def test_workflow_event_from_dict_accepts_explicit_allowed_types() -> None: """Request-info reconstruction accepts exact trusted custom types.""" diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index 560020f2fc1..d626486ac5a 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -304,6 +304,8 @@ def test_serialize_type() -> None: assert serialize_type(dict) == "builtins.dict" assert serialize_type(tuple) == "builtins.tuple" assert serialize_type(set) == "builtins.set" + # ``type(None)`` claims the builtins module even though it is bound as ``types.NoneType``. + assert serialize_type(type(None)) == "builtins.NoneType" # Test custom class @dataclass @@ -326,12 +328,13 @@ def test_deserialize_type() -> None: assert deserialize_type("builtins.dict") is dict assert deserialize_type("builtins.tuple") is tuple assert deserialize_type("builtins.set") is set + assert deserialize_type("builtins.NoneType") is type(None) def test_serialize_deserialize_roundtrip() -> None: """Test that serialization and deserialization are inverse operations.""" # Test built-in types - types_to_test = [int, str, float, bool, list, dict, tuple, set] + types_to_test = [int, str, float, bool, list, dict, tuple, set, type(None)] for type_to_test in types_to_test: serialized = serialize_type(type_to_test) diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 0b859bbe593..93f0c7c9ffd 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -378,6 +378,30 @@ def test_request_info_dataclass_arguments_are_serialized_when_content_is_created assert deserialized_args.request_event.data == HandoffRequest(target_agent="helper", reason="overflow") assert deserialized_args.request_event.response_type is str + def test_request_info_arguments_roundtrip_none_request_data(self) -> None: + """A request without payload and with a ``None`` response type survives the envelope round trip. + + ``type(None)`` serializes as ``builtins.NoneType``, a name the ``builtins`` module does not + bind, so rebuilding the arguments used to raise AttributeError for these requests. + """ + executor = SimpleExecutor(id="executor1", response_text="Response") + workflow = WorkflowBuilder(start_executor=executor).build() + agent = WorkflowAgent(workflow=workflow, name="None Request Agent") + event = WorkflowEvent.request_info( + request_id="request_123", + source_executor_id="executor1", + request_data=None, + response_type=type(None), + ) + + request_function_call = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage] + + deserialized_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(request_function_call.arguments) # ty: ignore[invalid-argument-type] + assert deserialized_args.request_id == "request_123" + assert deserialized_args.request_event.data is None + assert deserialized_args.request_event.request_type is type(None) + assert deserialized_args.request_event.response_type is type(None) + def test_request_info_function_args_from_dict_accepts_explicit_allowed_types(self) -> None: """Envelope reconstruction forwards exact trusted custom types.""" From 0082b2416b93db84df57693c2535b895d8acb85f Mon Sep 17 00:00:00 2001 From: Giraffe0129 <3012074063@qq.com> Date: Tue, 3 Nov 2026 08:15:17 +0800 Subject: [PATCH 2/3] Python: Enable postponed annotations in workflow typing utilities --- .../packages/core/agent_framework/_workflows/_typing_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 1570fa45ef3..7838c901120 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +from __future__ import annotations + import sys import typing from collections.abc import Callable, Mapping, Sequence From 2ce214ffb949443810fd566f926a841fc3bbdac3 Mon Sep 17 00:00:00 2001 From: Giraffe0129 <3012074063@qq.com> Date: Tue, 3 Nov 2026 17:44:30 +0800 Subject: [PATCH 3/3] Python: Narrow request-info arguments type in regression test --- python/packages/core/tests/workflow/test_workflow_agent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 1b72aebde85..a1c96fa6291 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -395,7 +395,8 @@ def test_request_info_arguments_roundtrip_none_request_data(self) -> None: request_function_call = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage] - deserialized_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(request_function_call.arguments) # ty: ignore[invalid-argument-type] + assert isinstance(request_function_call.arguments, dict) + deserialized_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(request_function_call.arguments) assert deserialized_args.request_id == "request_123" assert deserialized_args.request_event.data is None assert deserialized_args.request_event.request_type is type(None)