mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Bug fixes for devui serialization and agent structured outputs (#1055)
* Bug fixes for devui serialization and agent structured outputs * Fix typing * Add mapper serialization unit tests
This commit is contained in:
committed by
GitHub
Unverified
parent
2575e8cab0
commit
19b9aeec5f
@@ -2555,6 +2555,8 @@ class ChatOptions(SerializationMixin):
|
||||
other_tools = other.tools
|
||||
# tool_choice has a specialized serialize method. Save it here so we can fix it later.
|
||||
tool_choice = other.tool_choice or self.tool_choice
|
||||
# response_format is a class type that can't be serialized. Save it here so we can restore it later.
|
||||
response_format = self.response_format
|
||||
# Start with a shallow copy of self that preserves tool objects
|
||||
combined = ChatOptions.from_dict(self.to_dict())
|
||||
combined.tool_choice = self.tool_choice
|
||||
@@ -2562,6 +2564,7 @@ class ChatOptions(SerializationMixin):
|
||||
combined.logit_bias = dict(self.logit_bias) if self.logit_bias else None
|
||||
combined.metadata = dict(self.metadata) if self.metadata else None
|
||||
combined.additional_properties = dict(self.additional_properties)
|
||||
combined.response_format = response_format
|
||||
|
||||
# Apply scalar and mapping updates from the other options
|
||||
updated_data = other.to_dict(exclude_none=True, exclude={"tools"})
|
||||
@@ -2573,6 +2576,9 @@ class ChatOptions(SerializationMixin):
|
||||
setattr(combined, key, value)
|
||||
|
||||
combined.tool_choice = tool_choice
|
||||
# Preserve response_format from other if it exists, otherwise keep self's
|
||||
if other.response_format is not None:
|
||||
combined.response_format = other.response_format
|
||||
combined.instructions = "\n".join([combined.instructions or "", other.instructions or ""])
|
||||
combined.logit_bias = {**(combined.logit_bias or {}), **logit_bias}
|
||||
combined.metadata = {**(combined.metadata or {}), **metadata}
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Union
|
||||
|
||||
@@ -257,12 +258,13 @@ class MessageMapper:
|
||||
List of OpenAI response stream events
|
||||
"""
|
||||
try:
|
||||
serialized_payload = self._serialize_payload(getattr(event, "data", None))
|
||||
# Create structured workflow event
|
||||
workflow_event = ResponseWorkflowEventComplete(
|
||||
type="response.workflow_event.complete",
|
||||
data={
|
||||
"event_type": event.__class__.__name__,
|
||||
"data": getattr(event, "data", None),
|
||||
"data": serialized_payload,
|
||||
"executor_id": getattr(event, "executor_id", None),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
@@ -278,6 +280,59 @@ class MessageMapper:
|
||||
logger.warning(f"Error converting workflow event: {e}")
|
||||
return [await self._create_error_event(str(e), context)]
|
||||
|
||||
def _serialize_payload(self, value: Any) -> Any:
|
||||
"""Best-effort JSON serialization for workflow payloads."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [self._serialize_payload(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): self._serialize_payload(v) for k, v in value.items()}
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
try:
|
||||
return self._serialize_payload(asdict(value))
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize dataclass payload: %s", exc)
|
||||
model_dump_method = getattr(value, "model_dump", None)
|
||||
if model_dump_method is not None and callable(model_dump_method):
|
||||
try:
|
||||
dumped = model_dump_method()
|
||||
return self._serialize_payload(dumped)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via model_dump: %s", exc)
|
||||
dict_method = getattr(value, "dict", None)
|
||||
if dict_method is not None and callable(dict_method):
|
||||
try:
|
||||
dict_result = dict_method()
|
||||
return self._serialize_payload(dict_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via dict(): %s", exc)
|
||||
to_dict_method = getattr(value, "to_dict", None)
|
||||
if to_dict_method is not None and callable(to_dict_method):
|
||||
try:
|
||||
to_dict_result = to_dict_method()
|
||||
return self._serialize_payload(to_dict_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via to_dict(): %s", exc)
|
||||
model_dump_json_method = getattr(value, "model_dump_json", None)
|
||||
if model_dump_json_method is not None and callable(model_dump_json_method):
|
||||
try:
|
||||
json_str = model_dump_json_method()
|
||||
if isinstance(json_str, (str, bytes, bytearray)):
|
||||
return json.loads(json_str)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via model_dump_json: %s", exc)
|
||||
if hasattr(value, "__dict__"):
|
||||
try:
|
||||
return self._serialize_payload({
|
||||
key: self._serialize_payload(val) for key, val in value.__dict__.items() if not key.startswith("_")
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to serialize payload via __dict__: %s", exc)
|
||||
return str(value)
|
||||
|
||||
# Content type mappers - implementing our comprehensive mapping plan
|
||||
|
||||
async def _map_text_content(self, content: Any, context: dict[str, Any]) -> ResponseTextDeltaEvent:
|
||||
|
||||
@@ -158,6 +158,217 @@ async def test_unknown_content_fallback(mapper: MessageMapper, test_request: Age
|
||||
assert "WeirdUnknownContent" in event.delta
|
||||
|
||||
|
||||
def test_serialize_payload_primitives(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of primitive types."""
|
||||
assert mapper._serialize_payload(None) is None
|
||||
assert mapper._serialize_payload("test") == "test"
|
||||
assert mapper._serialize_payload(42) == 42
|
||||
assert mapper._serialize_payload(3.14) == 3.14
|
||||
assert mapper._serialize_payload(True) is True
|
||||
assert mapper._serialize_payload(False) is False
|
||||
|
||||
|
||||
def test_serialize_payload_sequences(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of lists, tuples, and sets."""
|
||||
# List
|
||||
result = mapper._serialize_payload([1, 2, "three"])
|
||||
assert result == [1, 2, "three"]
|
||||
assert isinstance(result, list)
|
||||
|
||||
# Tuple - should convert to list
|
||||
result = mapper._serialize_payload((1, 2, "three"))
|
||||
assert result == [1, 2, "three"]
|
||||
assert isinstance(result, list)
|
||||
|
||||
# Set - should convert to list (order may vary)
|
||||
result = mapper._serialize_payload({1, 2, 3})
|
||||
assert isinstance(result, list)
|
||||
assert set(result) == {1, 2, 3}
|
||||
|
||||
# Nested sequences
|
||||
result = mapper._serialize_payload([1, [2, 3], (4, 5)])
|
||||
assert result == [1, [2, 3], [4, 5]]
|
||||
|
||||
|
||||
def test_serialize_payload_dicts(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of dictionaries."""
|
||||
# Simple dict
|
||||
result = mapper._serialize_payload({"a": 1, "b": 2})
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
# Dict with non-string keys (should convert to string)
|
||||
result = mapper._serialize_payload({1: "one", 2: "two"})
|
||||
assert result == {"1": "one", "2": "two"}
|
||||
|
||||
# Nested dicts
|
||||
result = mapper._serialize_payload({"outer": {"inner": {"deep": 42}}})
|
||||
assert result == {"outer": {"inner": {"deep": 42}}}
|
||||
|
||||
# Dict with mixed value types
|
||||
result = mapper._serialize_payload({"str": "text", "num": 123, "list": [1, 2], "dict": {"nested": True}})
|
||||
assert result == {"str": "text", "num": 123, "list": [1, 2], "dict": {"nested": True}}
|
||||
|
||||
|
||||
def test_serialize_payload_dataclass(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of dataclasses."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
active: bool = True
|
||||
|
||||
person = Person(name="Alice", age=30)
|
||||
result = mapper._serialize_payload(person)
|
||||
|
||||
assert result == {"name": "Alice", "age": 30, "active": True}
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_serialize_payload_pydantic_model(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of Pydantic models."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool = True
|
||||
|
||||
user = User(username="testuser", email="test@example.com")
|
||||
result = mapper._serialize_payload(user)
|
||||
|
||||
assert result == {"username": "testuser", "email": "test@example.com", "is_active": True}
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_serialize_payload_nested_pydantic(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of nested Pydantic models."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str
|
||||
city: str
|
||||
|
||||
class Person(BaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
person = Person(name="Bob", address=Address(street="123 Main St", city="Springfield"))
|
||||
result = mapper._serialize_payload(person)
|
||||
|
||||
assert result == {"name": "Bob", "address": {"street": "123 Main St", "city": "Springfield"}}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_dict_method(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with dict() method."""
|
||||
|
||||
class CustomObject:
|
||||
def __init__(self):
|
||||
self.value = 42
|
||||
|
||||
def dict(self):
|
||||
return {"value": self.value, "type": "custom"}
|
||||
|
||||
obj = CustomObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == {"value": 42, "type": "custom"}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_to_dict_method(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with to_dict() method."""
|
||||
|
||||
class CustomObject:
|
||||
def __init__(self):
|
||||
self.value = 42
|
||||
|
||||
def to_dict(self):
|
||||
return {"value": self.value, "type": "custom_to_dict"}
|
||||
|
||||
obj = CustomObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == {"value": 42, "type": "custom_to_dict"}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_model_dump_json(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with model_dump_json() method."""
|
||||
import json
|
||||
|
||||
class CustomObject:
|
||||
def __init__(self):
|
||||
self.value = 42
|
||||
|
||||
def model_dump_json(self):
|
||||
return json.dumps({"value": self.value, "type": "json_dump"})
|
||||
|
||||
obj = CustomObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == {"value": 42, "type": "json_dump"}
|
||||
|
||||
|
||||
def test_serialize_payload_object_with_dict_attr(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of objects with __dict__ attribute."""
|
||||
|
||||
class SimpleObject:
|
||||
def __init__(self):
|
||||
self.public_value = 42
|
||||
self._private_value = 100 # Should be excluded
|
||||
|
||||
obj = SimpleObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert "public_value" in result
|
||||
assert result["public_value"] == 42
|
||||
assert "_private_value" not in result
|
||||
|
||||
|
||||
def test_serialize_payload_fallback_to_string(mapper: MessageMapper) -> None:
|
||||
"""Test that unserializable objects fall back to string representation."""
|
||||
|
||||
class WeirdObject:
|
||||
__slots__ = () # Prevent __dict__ attribute
|
||||
|
||||
def __str__(self):
|
||||
return "weird_object_string"
|
||||
|
||||
obj = WeirdObject()
|
||||
result = mapper._serialize_payload(obj)
|
||||
|
||||
assert result == "weird_object_string"
|
||||
|
||||
|
||||
def test_serialize_payload_complex_nested(mapper: MessageMapper) -> None:
|
||||
"""Test serialization of complex nested structures."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@dataclass
|
||||
class DataItem:
|
||||
value: int
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
enabled: bool
|
||||
count: int
|
||||
|
||||
complex_data = {
|
||||
"items": [DataItem(value=1), DataItem(value=2)],
|
||||
"config": ConfigModel(enabled=True, count=5),
|
||||
"nested": {"list": [1, 2, 3], "tuple": (4, 5, 6)},
|
||||
"primitive": 42,
|
||||
}
|
||||
|
||||
result = mapper._serialize_payload(complex_data)
|
||||
|
||||
assert result["items"] == [{"value": 1}, {"value": 2}]
|
||||
assert result["config"] == {"enabled": True, "count": 5}
|
||||
assert result["nested"] == {"list": [1, 2, 3], "tuple": [4, 5, 6]}
|
||||
assert result["primitive"] == 42
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Simple test runner
|
||||
async def run_all_tests() -> None:
|
||||
@@ -166,7 +377,7 @@ if __name__ == "__main__":
|
||||
model="agent-framework", input="Test", stream=True, extra_body=AgentFrameworkExtraBody(entity_id="test")
|
||||
)
|
||||
|
||||
tests = [
|
||||
async_tests = [
|
||||
("Critical isinstance bug detection", test_critical_isinstance_bug_detection),
|
||||
("Text content mapping", test_text_content_mapping),
|
||||
("Function call mapping", test_function_call_mapping),
|
||||
@@ -175,12 +386,34 @@ if __name__ == "__main__":
|
||||
("Unknown content fallback", test_unknown_content_fallback),
|
||||
]
|
||||
|
||||
sync_tests = [
|
||||
("Serialize primitives", test_serialize_payload_primitives),
|
||||
("Serialize sequences", test_serialize_payload_sequences),
|
||||
("Serialize dicts", test_serialize_payload_dicts),
|
||||
("Serialize dataclass", test_serialize_payload_dataclass),
|
||||
("Serialize pydantic model", test_serialize_payload_pydantic_model),
|
||||
("Serialize nested pydantic", test_serialize_payload_nested_pydantic),
|
||||
("Serialize dict method", test_serialize_payload_object_with_dict_method),
|
||||
("Serialize to_dict method", test_serialize_payload_object_with_to_dict_method),
|
||||
("Serialize model_dump_json", test_serialize_payload_object_with_model_dump_json),
|
||||
("Serialize __dict__ attr", test_serialize_payload_object_with_dict_attr),
|
||||
("Serialize fallback to string", test_serialize_payload_fallback_to_string),
|
||||
("Serialize complex nested", test_serialize_payload_complex_nested),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
for _test_name, test_func in tests:
|
||||
for _test_name, test_func in async_tests:
|
||||
try:
|
||||
await test_func(mapper, test_request)
|
||||
passed += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _test_name, test_func in sync_tests:
|
||||
try:
|
||||
test_func(mapper)
|
||||
passed += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.run(run_all_tests())
|
||||
|
||||
Reference in New Issue
Block a user