diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index aeb06f1b18..dabec534e9 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -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} diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index 30a9058677..a4028e207c 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -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: diff --git a/python/packages/devui/tests/test_mapper.py b/python/packages/devui/tests/test_mapper.py index 3ff6797ebd..70c3f8cd6b 100644 --- a/python/packages/devui/tests/test_mapper.py +++ b/python/packages/devui/tests/test_mapper.py @@ -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())