Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a33e803
Adding skeleton for sub-channel handling
rodrigobr-msft Oct 7, 2025
ac741d5
ChannelId test setup
rodrigobr-msft Oct 8, 2025
0fca846
Defining serialized and validators for Activity and ChannelId
rodrigobr-msft Oct 8, 2025
1c3fd96
Passing ChannelId tests
rodrigobr-msft Oct 8, 2025
9b8532a
Fixing imports
rodrigobr-msft Oct 8, 2025
14d2f3d
Reorganizing Activity tests
rodrigobr-msft Oct 8, 2025
e10f9ab
Test adjustment
rodrigobr-msft Oct 8, 2025
2b13902
Adjusting setter/getter for _channel_id in Activity
rodrigobr-msft Oct 8, 2025
dbdedbc
Fixing test cases and finalizing Activity serializer
rodrigobr-msft Oct 8, 2025
0aeb75e
Tweaks to docstrings
rodrigobr-msft Oct 8, 2025
15a3c96
Merge branch 'main' of https://github.com/microsoft/Agents-for-python…
rodrigobr-msft Oct 8, 2025
7408a1e
Fixing merge conflicts
rodrigobr-msft Oct 8, 2025
f014443
Addressing review comments
rodrigobr-msft Oct 10, 2025
3da2c64
Addressing edge case
rodrigobr-msft Oct 10, 2025
c361f9d
Completed fix for serializing a None
rodrigobr-msft Oct 10, 2025
affcdd7
Refactoring to make ChannelId a subclass of str
rodrigobr-msft Oct 10, 2025
2f2894b
Updated implementation details
rodrigobr-msft Oct 13, 2025
bc79986
Removing Self import from typing
rodrigobr-msft Oct 13, 2025
c39c526
Addressing PR comments
rodrigobr-msft Oct 13, 2025
9128cfe
Merge branch 'main' into users/robrandao/sub-channels
rodrigobr-msft Oct 13, 2025
d4516ff
Addressing PR review and making entities subclass from Entity
rodrigobr-msft Oct 13, 2025
f508ae8
Raising exceptions when ProductInfo and channel_id.sub_channel conflict
rodrigobr-msft Oct 13, 2025
d040586
Merge branch 'main' of https://github.com/microsoft/Agents-for-python…
rodrigobr-msft Oct 13, 2025
3705d8a
Merge branch 'users/robrandao/sub-channels' of https://github.com/mic…
rodrigobr-msft Oct 13, 2025
26f1704
Adding copyright comment
rodrigobr-msft Oct 13, 2025
946da69
Reverting strenum usage
rodrigobr-msft Oct 13, 2025
32487fb
Removing unnecessary str conversion and unnecessary comments
rodrigobr-msft Oct 14, 2025
b2fbb0b
Merge branch 'main' into users/robrandao/sub-channels
rodrigobr-msft Oct 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from .agents_model import AgentsModel
from .action_types import ActionTypes
from .activity import Activity
Expand All @@ -17,6 +20,8 @@
from .card_image import CardImage
from .channels import Channels
from .channel_account import ChannelAccount
from ._channel_id_field_mixin import _ChannelIdFieldMixin
from .channel_id import ChannelId
from .conversation_account import ConversationAccount
from .conversation_members import ConversationMembers
from .conversation_parameters import ConversationParameters
Expand All @@ -26,6 +31,7 @@
from .expected_replies import ExpectedReplies
from .entity import (
Entity,
EntityTypes,
AIEntity,
ClientCitation,
ClientCitationAppearance,
Expand All @@ -36,6 +42,7 @@
SensitivityPattern,
GeoCoordinates,
Place,
ProductInfo,
Thing,
)
from .error import Error
Expand Down Expand Up @@ -115,6 +122,8 @@
"CardImage",
"Channels",
"ChannelAccount",
"ChannelId",
"_ChannelIdFieldMixin",
"ConversationAccount",
"ConversationMembers",
"ConversationParameters",
Expand Down Expand Up @@ -145,6 +154,7 @@
"OAuthCard",
"PagedMembersResult",
"Place",
"ProductInfo",
"ReceiptCard",
"ReceiptItem",
"ResourceResponse",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from __future__ import annotations

import logging
from typing import Optional, Any

from pydantic import (
ModelWrapValidatorHandler,
SerializerFunctionWrapHandler,
computed_field,
model_validator,
model_serializer,
)

from .channel_id import ChannelId

logger = logging.getLogger(__name__)


# can be generalized in the future, if needed
class _ChannelIdFieldMixin:
"""A mixin to add a computed field channel_id of type ChannelId to a Pydantic model."""

_channel_id: Optional[ChannelId] = None

# required to define the setter below
@computed_field(return_type=Optional[ChannelId], alias="channelId")
@property
def channel_id(self) -> Optional[ChannelId]:
"""Gets the _channel_id field"""
return self._channel_id

# necessary for backward compatibility
# previously, channel_id was directly assigned with strings
@channel_id.setter
def channel_id(self, value: Any):
"""Sets the channel_id after validating it as a ChannelId model."""
if isinstance(value, ChannelId):
self._channel_id = value
elif isinstance(value, str):
self._channel_id = ChannelId(value)
else:
raise ValueError(
f"Invalid type for channel_id: {type(value)}. "
"Expected ChannelId or str."
)

def _set_validated_channel_id(self, data: Any) -> None:
"""Sets the channel_id after validating it as a ChannelId model."""
if "channelId" in data:
self.channel_id = data["channelId"]
elif "channel_id" in data:
self.channel_id = data["channel_id"]

@model_validator(mode="wrap")
@classmethod
def _validate_channel_id(
cls, data: Any, handler: ModelWrapValidatorHandler
) -> _ChannelIdFieldMixin:
"""Validate the _channel_id field after model initialization.

:return: The model instance itself.
"""
try:
model = handler(data)
model._set_validated_channel_id(data)
return model
except Exception:
logging.error("Model %s failed to validate with data %s", cls, data)
raise

def _remove_serialized_unset_channel_id(
self, serialized: dict[str, object]
) -> None:
"""Remove the _channel_id field if it is not set."""
if not self._channel_id:
if "channelId" in serialized:
del serialized["channelId"]
elif "channel_id" in serialized:
del serialized["channel_id"]

@model_serializer(mode="wrap")
def _serialize_channel_id(
self, handler: SerializerFunctionWrapHandler
) -> dict[str, object]:
"""Serialize the model using Pydantic's standard serialization.

:param handler: The serialization handler provided by Pydantic.
:return: A dictionary representing the serialized model.
"""
serialized = handler(self)
if self: # serialization can be called with None
self._remove_serialized_unset_channel_id(serialized)
return serialized
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from __future__ import annotations

import logging
from copy import copy
from datetime import datetime, timezone
from typing import Optional
from pydantic import Field, SerializeAsAny
from typing import Optional, Any

from pydantic import (
Field,
SerializeAsAny,
model_serializer,
model_validator,
SerializerFunctionWrapHandler,
ModelWrapValidatorHandler,
computed_field,
ValidationError,
)

from .activity_types import ActivityTypes
from .channel_account import ChannelAccount
from .conversation_account import ConversationAccount
Expand All @@ -14,22 +28,28 @@
from .attachment import Attachment
from .entity import (
Entity,
EntityTypes,
Mention,
AIEntity,
ClientCitation,
ProductInfo,
SensitivityUsageInfo,
)
from .conversation_reference import ConversationReference
from .text_highlight import TextHighlight
from .semantic_action import SemanticAction
from .agents_model import AgentsModel
from .role_types import RoleTypes
from ._channel_id_field_mixin import _ChannelIdFieldMixin
from .channel_id import ChannelId
from ._model_utils import pick_model, SkipNone
from ._type_aliases import NonEmptyString

logger = logging.getLogger(__name__)


# TODO: A2A Agent 2 is responding with None as id, had to mark it as optional (investigate)
class Activity(AgentsModel):
class Activity(AgentsModel, _ChannelIdFieldMixin):
"""An Activity is the basic communication type for the protocol.

:param type: Contains the activity type. Possible values include:
Expand All @@ -50,8 +70,8 @@ class Activity(AgentsModel):
:type local_timezone: str
:param service_url: Contains the URL that specifies the channel's service endpoint. Set by the channel.
:type service_url: str
:param channel_id: Contains an ID that uniquely identifies the channel. Set by the channel.
:type channel_id: str
:param channel_id: Contains an ID that uniquely identifies the channel (and possibly the sub-channel). Set by the channel.
:type channel_id: ~microsoft_agents.activity.ChannelId
:param from_property: Identifies the sender of the message.
:type from_property: ~microsoft_agents.activity.ChannelAccount
:param conversation: Identifies the conversation to which the activity belongs.
Expand Down Expand Up @@ -136,7 +156,6 @@ class Activity(AgentsModel):
local_timestamp: datetime = None
local_timezone: NonEmptyString = None
service_url: NonEmptyString = None
channel_id: NonEmptyString = None
from_property: ChannelAccount = Field(None, alias="from")
conversation: ConversationAccount = None
recipient: ChannelAccount = None
Expand Down Expand Up @@ -173,6 +192,92 @@ class Activity(AgentsModel):
semantic_action: SemanticAction = None
caller_id: NonEmptyString = None

@model_validator(mode="wrap")
@classmethod
def _validate_channel_id(
cls, data: Any, handler: ModelWrapValidatorHandler[Activity]
) -> Activity:
"""Validate the Activity, ensuring consistency between channel_id.sub_channel and productInfo entity.

:param data: The input data to validate.
:param handler: The validation handler provided by Pydantic.
:return: The validated Activity instance.
"""
try:
# run Pydantic's standard validation first
activity = handler(data)

# needed to assign to a computed field
# needed because we override the mixin validator
activity._set_validated_channel_id(data)

# sync sub_channel with productInfo entity
product_info = activity.get_product_info_entity()
if product_info and activity.channel_id:
if (
activity.channel_id.sub_channel
and activity.channel_id.sub_channel != product_info.id
):
raise Exception(
"Conflict between channel_id.sub_channel and productInfo entity"
)
activity.channel_id = ChannelId(
channel=activity.channel_id.channel,
sub_channel=product_info.id,
)

return activity
except ValidationError as exc:
logger.error("Validation error for Activity: %s", exc, exc_info=True)
raise

@model_serializer(mode="wrap")
def _serialize_sub_channel_data(
self, handler: SerializerFunctionWrapHandler
) -> dict[str, object]:
"""Serialize the Activity, ensuring consistency between channel_id.sub_channel and productInfo entity.

:param handler: The serialization handler provided by Pydantic.
:return: A dictionary representing the serialized Activity.
"""

# run Pydantic's standard serialization first
serialized = handler(self)
if not self: # serialization can be called with None
return serialized

# find the ProductInfo entity
product_info = None
for i, entity in enumerate(serialized.get("entities") or []):
if entity.get("type", "") == EntityTypes.PRODUCT_INFO:
product_info = entity
break

# maintain consistency between ProductInfo entity and sub channel
if self.channel_id and self.channel_id.sub_channel:
if product_info and product_info.get("id") != self.channel_id.sub_channel:
raise Exception(
"Conflict between channel_id.sub_channel and productInfo entity"
)
elif not product_info:
if not serialized.get("entities"):
serialized["entities"] = []
serialized["entities"].append(
{
"type": EntityTypes.PRODUCT_INFO,
"id": self.channel_id.sub_channel,
}
)
elif product_info: # remove productInfo entity if sub_channel is not set
del serialized["entities"][i]
if not serialized["entities"]: # after removal above, list may be empty
del serialized["entities"]

# necessary due to computed_field serialization
self._remove_serialized_unset_channel_id(serialized)

return serialized

def apply_conversation_reference(
self, reference: ConversationReference, is_incoming: bool = False
):
Expand Down Expand Up @@ -531,6 +636,14 @@ def get_conversation_reference(self) -> ConversationReference:
service_url=self.service_url,
)

def get_product_info_entity(self) -> Optional[ProductInfo]:
if not self.entities:
return None
target = EntityTypes.PRODUCT_INFO.lower()
# validated entities can be Entity, and that prevents us from
# making assumptions about the casing of the 'type' attribute
return next(filter(lambda e: e.type.lower() == target, self.entities), None)

def get_mentions(self) -> list[Mention]:
"""
Resolves the mentions from the entities of this activity.
Expand All @@ -543,7 +656,7 @@ def get_mentions(self) -> list[Mention]:
"""
if not self.entities:
return []
return [x for x in self.entities if x.type.lower() == "mention"]
return [x for x in self.entities if x.type.lower() == EntityTypes.MENTION]

def get_reply_conversation_reference(
self, reply: ResourceResponse
Expand Down
Loading