chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,18 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata, CoreAgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType, CoreAgentType
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
__all__ = [
"AgentId",
"AgentMetadata",
"AgentType",
"BaseAgent",
"CoreAgentId",
"CoreAgentMetadata",
"CoreAgentType",
"CoreRuntime",
]
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Mapping
from typing import Any, Protocol, runtime_checkable
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class Agent(Protocol):
"""Protocol for an agent."""
@property
def metadata(self) -> AgentMetadata:
"""Metadata of the agent."""
...
@property
def id(self) -> AgentId:
"""ID of the agent."""
...
async def on_message(self, message: Any, ctx: MessageContext) -> Any:
"""Message handler for the agent. This should only be called by the runtime, not by other agents.
Args:
message (Any): Received message. Type is one of the types in `subscriptions`.
ctx (MessageContext): Context of the message.
Returns:
Any: Response to the message. Can be None.
Raises:
asyncio.CancelledError: If the message was cancelled.
CantHandleException: If the agent cannot handle the message.
"""
...
async def save_state(self) -> Mapping[str, Any]:
"""Save the state of the agent. The result must be JSON serializable."""
...
async def load_state(self, state: Mapping[str, Any]) -> None:
"""Load in the state of the agent obtained from `save_state`.
Args:
state (Mapping[str, Any]): State of the agent. Must be JSON serializable.
"""
...
async def close(self) -> None:
"""Called when the runtime is closed."""
...
@@ -0,0 +1,102 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from typing import Protocol, runtime_checkable
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.validation_utils import is_valid_agent_type
@experimental
@runtime_checkable
class AgentId(Protocol):
"""Defines the minimal interface an AgentId.
It must fulfill a 'type' and a 'key' that identify the agent instance.
"""
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
...
@property
def key(self) -> str:
"""Defines the unique instance key within the agent type."""
...
def __eq__(self, other: object) -> bool:
"""Equality check must differentiate between different IDs."""
...
def __hash__(self) -> int:
"""Hash value needed to store AgentIds in sets/dicts."""
...
def __str__(self) -> str:
"""String representation of the AgentId, e.g. 'type/key'."""
...
@experimental
class CoreAgentId(AgentId):
"""Core implementation of the AgentId protocol."""
def __init__(self, type: str | AgentType, key: str) -> None:
"""Initialize the AgentId with the given type and key."""
# If `type` is itself an AgentType, extract the string property.
if isinstance(type, AgentType):
type = type.type
if not is_valid_agent_type(type):
raise ValueError(
rf"Invalid agent type: {type}. "
r"Allowed values MUST match the regex: `^[\w\-\.]+\Z`"
)
self._type = type
self._key = key
@classmethod
def from_str(cls, agent_id: str) -> Self:
"""Convert a string of the format ``type/key`` into a CoreAgentId."""
items = agent_id.split("/", maxsplit=1)
if len(items) != 2:
raise ValueError(f"Invalid agent id: {agent_id}")
t, k = items[0], items[1]
return cls(t, k)
@property
def type(self) -> str:
r"""The agent's 'type' (or category). Must match `^[\\w\\-\\.]+$`."""
return self._type
@property
def key(self) -> str:
"""The agent's instance key, e.g. 'default' or a unique identifier."""
return self._key
def __eq__(self, value: object) -> bool:
"""Check if two AgentIds are equal by comparing 'type' and 'key'."""
if not isinstance(value, AgentId):
return False
return (self.type == value.type) and (self.key == value.key)
def __hash__(self) -> int:
"""Generate a hash so we can store AgentIds in sets/dicts."""
return hash((self._type, self._key))
def __str__(self) -> str:
"""Convert the AgentId to a user-friendly string."""
return f"{self._type}/{self._key}"
def __repr__(self) -> str:
"""Generate a detailed string representation."""
return f'CoreAgentId(type="{self._type}", key="{self._key}")'
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Protocol, runtime_checkable
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class AgentMetadata(Protocol):
"""Provides a description for an agent: type, key, and an optional 'description' field."""
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
...
@property
def key(self) -> str:
"""Defines the 'key' or identifier of the agent."""
...
@property
def description(self) -> str:
"""Defines the 'description' of the agent."""
...
@experimental
class CoreAgentMetadata(AgentMetadata):
"""Concrete immutable implementation of AgentMetadata."""
_type: str
_key: str
_description: str
def __init__(self, type: str, key: str, description: str = "") -> None:
"""Initialize the agent metadata."""
self._type = type
self._key = key
self._description = description
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
return self._type
@property
def key(self) -> str:
"""Defines the 'key' or identifier of the agent."""
return self._key
@property
def description(self) -> str:
"""Defines the 'description' of the agent."""
return self._description
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class AgentType(Protocol):
"""Defines the minimal interface an AgentType."""
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
...
@experimental
@dataclass(eq=True, frozen=True)
class CoreAgentType:
"""Concrete immutable implementation of AgentType."""
_type: str
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
return self._type
def __str__(self) -> str:
"""Return the string representation of the agent type."""
return self._type
@@ -0,0 +1,221 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import warnings
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, ClassVar, TypeVar, final
from typing_extensions import Self
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata, CoreAgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType, CoreAgentType
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.serialization import MessageSerializer, try_get_known_serializers_for_type
from semantic_kernel.agents.runtime.core.subscription import Subscription, UnboundSubscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.agent_instantiation_context import AgentInstantiationContext
from semantic_kernel.agents.runtime.in_process.subscription_context import SubscriptionInstantiationContext
from semantic_kernel.agents.runtime.in_process.type_prefix_subscription import TypePrefixSubscription
from semantic_kernel.utils.feature_stage_decorator import experimental
T = TypeVar("T", bound=Agent)
BaseAgentType = TypeVar("BaseAgentType", bound="BaseAgent")
# Decorator for adding an unbound subscription to an agent
@experimental
def subscription_factory(subscription: UnboundSubscription) -> Callable[[type[BaseAgentType]], type[BaseAgentType]]:
"""Decorator for adding an unbound subscription to an agent."""
def decorator(cls: type[BaseAgentType]) -> type[BaseAgentType]:
cls.internal_unbound_subscriptions_list.append(subscription)
return cls
return decorator
@experimental
def handles(
msg_type: type[Any], serializer: MessageSerializer[Any] | list[MessageSerializer[Any]] | None = None
) -> Callable[[type[BaseAgentType]], type[BaseAgentType]]:
"""Decorator for associating a message type and corresponding serializer(s) with a BaseAgent or its subclass."""
def decorator(cls: type[BaseAgentType]) -> type[BaseAgentType]:
if serializer is None:
serializer_list = try_get_known_serializers_for_type(msg_type)
else:
serializer_list = [serializer] if not isinstance(serializer, Sequence) else list(serializer)
if not serializer_list:
raise ValueError(f"No serializers found for type {msg_type!r}. Please provide an explicit serializer.")
cls.internal_extra_handles_types.append((msg_type, serializer_list))
return cls
return decorator
@experimental
class BaseAgent(ABC, Agent):
"""Base class for all agents."""
internal_unbound_subscriptions_list: ClassVar[list[UnboundSubscription]] = []
""":meta private:"""
internal_extra_handles_types: ClassVar[list[tuple[type[Any], list[MessageSerializer[Any]]]]] = []
""":meta private:"""
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Initialize the class."""
super().__init_subclass__(**kwargs)
# Automatically set class_variable in each subclass so that they are not shared between subclasses
cls.internal_extra_handles_types = []
cls.internal_unbound_subscriptions_list = []
@classmethod
def _handles_types(cls) -> list[tuple[type[Any], list[MessageSerializer[Any]]]]:
return cls.internal_extra_handles_types
@classmethod
def _unbound_subscriptions(cls) -> list[UnboundSubscription]:
return cls.internal_unbound_subscriptions_list
@property
def metadata(self) -> AgentMetadata:
"""Get the metadata for this agent."""
assert self._id is not None # nosec
return CoreAgentMetadata(key=self._id.key, type=self._id.type, description=self._description)
def __init__(self, description: str) -> None:
"""Initialize the agent."""
try:
runtime = AgentInstantiationContext.current_runtime()
id = AgentInstantiationContext.current_agent_id()
except LookupError as e:
raise RuntimeError(
"BaseAgent must be instantiated within the context of an AgentRuntime. It cannot be directly "
"instantiated."
) from e
self._runtime: CoreRuntime = runtime
self._id: AgentId = id
if not isinstance(description, str):
raise ValueError("Agent description must be a string")
self._description = description
@property
def type(self) -> str:
"""Get the type of the agent."""
return self.id.type
@property
def id(self) -> AgentId:
"""Get the id of the agent."""
return self._id
@property
def runtime(self) -> CoreRuntime:
"""Get the runtime of the agent."""
return self._runtime
@final
async def on_message(self, message: Any, ctx: MessageContext) -> Any:
"""Handle a message sent to this agent."""
return await self.on_message_impl(message, ctx)
@abstractmethod
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any:
"""Handle a message sent to this agent."""
...
async def send_message(
self,
message: Any,
recipient: AgentId,
*,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> Any:
"""Send a message to another agent."""
if cancellation_token is None:
cancellation_token = CancellationToken()
return await self._runtime.send_message(
message,
sender=self.id,
recipient=recipient,
cancellation_token=cancellation_token,
message_id=message_id,
)
async def publish_message(
self,
message: Any,
topic_id: TopicId,
*,
cancellation_token: CancellationToken | None = None,
) -> None:
"""Publish a message."""
await self._runtime.publish_message(message, topic_id, sender=self.id, cancellation_token=cancellation_token)
async def save_state(self) -> Mapping[str, Any]:
"""Save the state of the agent."""
warnings.warn("save_state not implemented", stacklevel=2)
return {}
async def load_state(self, state: Mapping[str, Any]) -> None:
"""Load the state of the agent."""
warnings.warn("load_state not implemented", stacklevel=2)
pass
async def close(self) -> None:
"""Close the agent."""
pass
@classmethod
async def register(
cls,
runtime: CoreRuntime,
type: str,
factory: Callable[[], Self | Awaitable[Self]],
*,
skip_class_subscriptions: bool = False,
skip_direct_message_subscription: bool = False,
) -> AgentType:
"""Register the agent with the runtime."""
agent_type = CoreAgentType(type)
agent_type = await runtime.register_factory(type=agent_type, agent_factory=factory, expected_class=cls) # type: ignore
if not skip_class_subscriptions:
with SubscriptionInstantiationContext.populate_context(agent_type):
subscriptions: list[Subscription] = []
for unbound_subscription in cls._unbound_subscriptions():
subscriptions_list_result = unbound_subscription()
if inspect.isawaitable(subscriptions_list_result):
subscriptions_list = await subscriptions_list_result
else:
subscriptions_list = subscriptions_list_result
subscriptions.extend(subscriptions_list)
for subscription in subscriptions:
await runtime.add_subscription(subscription)
if not skip_direct_message_subscription:
# Additionally adds a special prefix subscription for this agent to receive direct messages
await runtime.add_subscription(
TypePrefixSubscription(
# The prefix MUST include ":" to avoid collisions with other agents
topic_type_prefix=agent_type.type + ":",
agent_type=agent_type.type,
)
)
# TODO(evmattso): deduplication
for _message_type, serializer in cls._handles_types():
runtime.add_message_serializer(serializer)
return agent_type
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
import threading
from asyncio import Future
from collections.abc import Callable
from typing import Any
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class CancellationToken:
"""A token used to cancel pending async calls."""
def __init__(self) -> None:
"""Initialize the CancellationToken."""
self._cancelled: bool = False
self._lock: threading.Lock = threading.Lock()
self._callbacks: list[Callable[[], None]] = []
def cancel(self) -> None:
"""Cancel pending async calls linked to this cancellation token."""
with self._lock:
if not self._cancelled:
self._cancelled = True
for callback in self._callbacks:
callback()
def is_cancelled(self) -> bool:
"""Check if the CancellationToken has been used."""
with self._lock:
return self._cancelled
def add_callback(self, callback: Callable[[], None]) -> None:
"""Attach a callback that will be called when cancel is invoked."""
with self._lock:
if self._cancelled:
callback()
else:
self._callbacks.append(callback)
def link_future(self, future: Future[Any]) -> Future[Any]:
"""Link a pending async call to a token to allow its cancellation."""
with self._lock:
if self._cancelled:
future.cancel()
else:
def _cancel() -> None:
future.cancel()
self._callbacks.append(_cancel)
return future
@@ -0,0 +1,204 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Protocol, TypeVar, overload, runtime_checkable
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.serialization import MessageSerializer
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
# Undeliverable - error
T = TypeVar("T", bound=Agent)
@experimental
@runtime_checkable
class CoreRuntime(Protocol):
"""CoreRuntime is the main entry point for the agent runtime.
It is responsible for managing agents and their interactions.
"""
async def send_message(
self,
message: Any,
recipient: AgentId,
*,
sender: AgentId | None = None,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> Any:
"""Send a message to an agent and get a response.
Args:
message (Any): The message to send.
recipient (AgentId): The agent to send the message to.
sender (AgentId | None, optional): Agent which sent the message. Should **only** be None if this was sent
from no agent, such as directly to the runtime externally. Defaults to None.
cancellation_token (CancellationToken | None, optional): Token used to cancel an in progress.
Defaults to None.
message_id (str | None, optional): The message id. If None, a new message id will be generated.
Raises:
CantHandleException: If the recipient cannot handle the message.
UndeliverableException: If the message cannot be delivered.
Other: Any other exception raised by the recipient.
Returns:
Any: The response from the agent.
"""
...
async def publish_message(
self,
message: Any,
topic_id: TopicId,
*,
sender: AgentId | None = None,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> None:
"""Publish a message to all agents in the given namespace.
If no namespace is provided, the namespace of the sender. No responses are expected from publishing.
Args:
message (Any): The message to publish.
topic_id (TopicId): The topic to publish the message to.
sender (AgentId | None, optional): The agent which sent the message. Defaults to None.
cancellation_token (CancellationToken | None, optional): Token used to cancel an in progress.
Defaults to None.
message_id (str | None, optional): The message id. If None, a new message id will be generated.
Defaults to None. This message id must be unique. and is recommended to be a UUID.
Raises:
UndeliverableException: If the message cannot be delivered.
"""
...
async def register_factory(
self,
type: str | AgentType,
agent_factory: Callable[[], T | Awaitable[T]],
*,
expected_class: type[T] | None = None,
) -> AgentType:
"""Register an agent factory with the runtime associated with a specific type. The type must be unique.
This API does not add any subscriptions.
Args:
type (str): The type of agent this factory creates. It is not the same as agent class name.
The `type` parameter is used to differentiate between different factory functions rather than
agent classes.
agent_factory (Callable[[], T]): The factory that creates the agent, where T is a concrete Agent type.
Inside the factory, use `agent_runtime.AgentInstantiationContext` to access variables like the current
runtime and agent ID.
expected_class (type[T] | None, optional): The expected class of the agent, used for runtime validation
of the factory. Defaults to None. If None, no validation is performed.
"""
...
# TODO(evmattso): uncomment out the following type ignore when this is fixed in mypy: https://github.com/python/mypy/issues/3737
async def try_get_underlying_agent_instance(self, id: AgentId, type: type[T] = Agent) -> T: # type: ignore[assignment]
"""Try to get the underlying agent instance by name and namespace.
This is generally discouraged (hence the long name), but can be useful in some cases.
If the underlying agent is not accessible, this will raise an exception.
Args:
id (AgentId): The agent id.
type (Type[T], optional): The expected type of the agent. Defaults to Agent.
Returns:
T: The concrete agent instance.
Raises:
LookupError: If the agent is not found.
NotAccessibleError: If the agent is not accessible, for example if it is located remotely.
TypeError: If the agent is not of the expected type.
"""
...
@overload
async def get(self, id: AgentId, /, *, lazy: bool = ...) -> AgentId: ...
@overload
async def get(self, type: AgentType | str, /, key: str = ..., *, lazy: bool = ...) -> AgentId: ...
async def get(
self, id_or_type: AgentId | AgentType | str, /, key: str = "default", *, lazy: bool = True
) -> AgentId:
"""Get an agent by id or type."""
...
async def agent_metadata(self, agent: AgentId) -> AgentMetadata:
"""Get the metadata for an agent.
Args:
agent (AgentId): The agent id.
Returns:
AgentMetadata: The agent metadata.
"""
...
async def agent_save_state(self, agent: AgentId) -> Mapping[str, Any]:
"""Save the state of a single agent.
The structure of the state is implementation defined and can be any JSON serializable object.
Args:
agent (AgentId): The agent id.
Returns:
Mapping[str, Any]: The saved state.
"""
...
async def agent_load_state(self, agent: AgentId, state: Mapping[str, Any]) -> None:
"""Load the state of a single agent.
Args:
agent (AgentId): The agent id.
state (Mapping[str, Any]): The saved state.
"""
...
async def add_subscription(self, subscription: Subscription) -> None:
"""Add a new subscription that the runtime should fulfill when processing published messages.
Args:
subscription (Subscription): The subscription to add
"""
...
async def remove_subscription(self, id: str) -> None:
"""Remove a subscription from the runtime.
Args:
id (str): id of the subscription to remove
Raises:
LookupError: If the subscription does not exist
"""
...
def add_message_serializer(self, serializer: MessageSerializer[Any] | Sequence[MessageSerializer[Any]]) -> None:
"""Add a new message serialization serializer to the runtime.
Note: This will deduplicate serializers based on the type_name and data_content_type properties
Args:
serializer (MessageSerializer[Any] | Sequence[MessageSerializer[Any]]): The serializer/s to add
"""
...
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
__all__ = ["CantHandleException", "MessageDroppedException", "NotAccessibleError", "UndeliverableException"]
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class CantHandleException(Exception):
"""Raised when a handler can't handle the exception."""
@experimental
class UndeliverableException(Exception):
"""Raised when a message can't be delivered."""
@experimental
class MessageDroppedException(Exception):
"""Raised when a message is dropped."""
@experimental
class NotAccessibleError(Exception):
"""Tried to access a value that is not accessible. For example if it is remote cannot be accessed locally."""
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any, Protocol, final
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.utils.feature_stage_decorator import experimental
__all__ = [
"DefaultInterventionHandler",
"DropMessage",
"InterventionHandler",
]
@experimental
@final
class DropMessage:
"""Marker type for signalling that a message should be dropped by an intervention handler.
The type itself should be returned from the handler.
"""
...
@experimental
class InterventionHandler(Protocol):
"""An intervention handler is a class that can be used to modify, log or drop messages.
These messages are being processed by the :class:`autogen_core.base.AgentRuntime`.
The handler is called when the message is submitted to the runtime.
Currently the only runtime which supports this is the :class:`autogen_core.base.SingleThreadedAgentRuntime`.
Note: Returning None from any of the intervention handler methods will result in a warning being issued and treated
as "no change". If you intend to drop a message, you should return :class:`DropMessage` explicitly.
"""
async def on_send(
self, message: Any, *, message_context: MessageContext, recipient: AgentId
) -> Any | type[DropMessage]:
"""Called when a message is submitted to the AgentRuntime."""
...
async def on_publish(self, message: Any, *, message_context: MessageContext) -> Any | type[DropMessage]:
"""Called when a message is published to the AgentRuntime."""
...
async def on_response(self, message: Any, *, sender: AgentId, recipient: AgentId | None) -> Any | type[DropMessage]:
"""Called when a response is received by the AgentRuntime from an Agent's message handler returning a value."""
...
@experimental
class DefaultInterventionHandler(InterventionHandler):
"""Simple class that provides a default implementation for all intervention handler methods.
Simply returns the message unchanged. Allows for easy
subclassing to override only the desired methods.
"""
async def on_send(
self, message: Any, *, message_context: MessageContext, recipient: AgentId
) -> Any | type[DropMessage]:
"""Called when a message is submitted to the AgentRuntime."""
return message
async def on_publish(self, message: Any, *, message_context: MessageContext) -> Any | type[DropMessage]:
"""Called when a message is published to the AgentRuntime."""
return message
async def on_response(self, message: Any, *, sender: AgentId, recipient: AgentId | None) -> Any | type[DropMessage]:
"""Called when a response is received by the AgentRuntime from an Agent's message handler returning a value."""
return message
@@ -0,0 +1,130 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from enum import Enum
from typing import Any
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class MessageKind(Enum):
"""Message kind enum."""
DIRECT = 1
PUBLISH = 2
RESPOND = 3
@experimental
class DeliveryStage(Enum):
"""Delivery stage enum."""
SEND = 1
DELIVER = 2
@experimental
class MessageEvent:
"""Base class for message events."""
def __init__(
self,
*,
payload: str,
sender: AgentId | None,
receiver: AgentId | TopicId | None,
kind: MessageKind,
delivery_stage: DeliveryStage,
**kwargs: Any,
) -> None:
"""Initialize a message event."""
self.kwargs = kwargs
self.kwargs["payload"] = payload
self.kwargs["sender"] = None if sender is None else str(sender)
self.kwargs["receiver"] = None if receiver is None else str(receiver)
self.kwargs["kind"] = str(kind)
self.kwargs["delivery_stage"] = str(delivery_stage)
self.kwargs["type"] = "Message"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@experimental
class MessageDroppedEvent:
"""Event for dropped messages."""
def __init__(
self,
*,
payload: str,
sender: AgentId | None,
receiver: AgentId | TopicId | None,
kind: MessageKind,
**kwargs: Any,
) -> None:
"""Initialize a message dropped event."""
self.kwargs = kwargs
self.kwargs["payload"] = payload
self.kwargs["sender"] = None if sender is None else str(sender)
self.kwargs["receiver"] = None if receiver is None else str(receiver)
self.kwargs["kind"] = str(kind)
self.kwargs["type"] = "MessageDropped"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@experimental
class MessageHandlerExceptionEvent:
"""Event for exceptions in message handlers."""
def __init__(
self,
*,
payload: str,
handling_agent: AgentId,
exception: BaseException,
**kwargs: Any,
) -> None:
"""Initialize a message handler exception event."""
self.kwargs = kwargs
self.kwargs["payload"] = payload
self.kwargs["handling_agent"] = str(handling_agent)
self.kwargs["exception"] = str(exception)
self.kwargs["type"] = "MessageHandlerException"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@experimental
class AgentConstructionExceptionEvent:
"""Event for exceptions during agent construction."""
def __init__(
self,
*,
agent_id: AgentId,
exception: BaseException,
**kwargs: Any,
) -> None:
"""Initialize an agent construction exception event."""
self.kwargs = kwargs
self.kwargs["agent_id"] = str(agent_id)
self.kwargs["exception"] = str(exception)
self.kwargs["type"] = "AgentConstructionException"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@dataclass
class MessageContext:
"""Context for a message sent to an agent."""
sender: AgentId | None
topic_id: TopicId | None
is_rpc: bool
cancellation_token: CancellationToken
message_id: str
@@ -0,0 +1,530 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Callable, Coroutine, Sequence
from functools import wraps
from typing import Any, DefaultDict, Literal, Protocol, TypeVar, cast, get_type_hints, overload, runtime_checkable
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.serialization import MessageSerializer, try_get_known_serializers_for_type
from semantic_kernel.agents.runtime.core.type_helpers import AnyType, get_types
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger("agent_runtime.core")
AgentT = TypeVar("AgentT")
ReceivesT = TypeVar("ReceivesT")
ProducesT = TypeVar("ProducesT", covariant=True)
# TODO(evmattso): Generic typevar bound binding U to agent type
# Can't do because python doesnt support it
# region MessageHandler Protocol and Methods
# Pyright and mypy disagree on the variance of ReceivesT. Mypy thinks it should be contravariant here.
# Revisit this later to see if we can remove the ignore.
@experimental
@runtime_checkable
class MessageHandler(Protocol[AgentT, ReceivesT, ProducesT]): # type: ignore
"""A protocol for message handlers."""
target_types: Sequence[type]
produces_types: Sequence[type]
is_message_handler: Literal[True]
router: Callable[[ReceivesT, MessageContext], bool]
# agent_instance binds to self in the method
@staticmethod
async def __call__(agent_instance: AgentT, message: ReceivesT, ctx: MessageContext) -> ProducesT:
"""Override the __call__ method to make this a callable class."""
...
# NOTE: this works on concrete types and not inheritance
# TODO(evmattso): Use a protocol for the outer function to check checked arg names
@experimental
@overload
def message_handler(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]: ...
@experimental
@overload
def message_handler(
func: None = None,
*,
match: None = ...,
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
@overload
def message_handler(
func: None = None,
*,
match: Callable[[ReceivesT, MessageContext], bool],
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
def message_handler(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]] | None = None,
*,
strict: bool = True,
match: Callable[[ReceivesT, MessageContext], bool] | None = None,
) -> (
Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]
| MessageHandler[AgentT, ReceivesT, ProducesT]
):
"""Decorator for generic message handlers.
Add this decorator to methods in a :class:`RoutedAgent` class that are intended to handle both
event and RPC messages.
These methods must have a specific signature that needs to be followed for it to be valid:
- The method must be an `async` method.
- The method must be decorated with the `@message_handler` decorator.
- The method must have exactly 3 arguments:
1. `self`
2. `message`: The message to be handled, this must be type-hinted with the message type that it is
intended to handle.
3. `ctx`: A :class:`agent_runtime.core.MessageContext` object.
- The method must be type hinted with what message types it can return as a response, or it can return `None` if
it does not return anything.
Handlers can handle more than one message type by accepting a Union of the message types. It can also return more
than one message type by returning a Union of the message types.
Args:
func: The function to be decorated.
strict: If `True`, the handler will raise an exception if the message type or return type is not in the target
types. If `False`, it will log a warning instead.
match: A function that takes the message and the context as arguments and returns a boolean. This is used for
secondary routing after the message type. For handlers addressing the same message type, the match function
is applied in alphabetical order of the handlers and the first matching handler will be called while the
rest are skipped. If `None`, the first handler in alphabetical order matching the same message type will
be called.
"""
def decorator(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]:
type_hints = get_type_hints(func)
if "message" not in type_hints:
raise AssertionError("message parameter not found in function signature")
if "return" not in type_hints:
raise AssertionError("return not found in function signature")
# Get the type of the message parameter
target_types = get_types(type_hints["message"])
if target_types is None:
raise AssertionError("Message type not found")
return_types = get_types(type_hints["return"])
if return_types is None:
raise AssertionError("Return type not found")
# Convert target_types to list and stash
@wraps(func)
async def wrapper(self: AgentT, message: ReceivesT, ctx: MessageContext) -> ProducesT:
if type(message) not in target_types:
if strict:
raise CantHandleException(f"Message type {type(message)} not in target types {target_types}")
logger.warning(f"Message type {type(message)} not in target types {target_types}")
return_value = await func(self, message, ctx)
if AnyType not in return_types and type(return_value) not in return_types:
if strict:
raise ValueError(f"Return type {type(return_value)} not in return types {return_types}")
logger.warning(f"Return type {type(return_value)} not in return types {return_types}")
return return_value
wrapper_handler = cast(MessageHandler[AgentT, ReceivesT, ProducesT], wrapper)
wrapper_handler.target_types = list(target_types)
wrapper_handler.produces_types = list(return_types)
wrapper_handler.is_message_handler = True
wrapper_handler.router = match or (lambda _message, _ctx: True)
return wrapper_handler
if func is None and not callable(func):
return decorator
if callable(func):
return decorator(func)
raise ValueError("Invalid arguments")
# endregion
# region Message Handler Decorators
@experimental
@overload
def event(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]],
) -> MessageHandler[AgentT, ReceivesT, None]: ...
@experimental
@overload
def event(
func: None = None,
*,
match: None = ...,
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]]],
MessageHandler[AgentT, ReceivesT, None],
]: ...
@experimental
@overload
def event(
func: None = None,
*,
match: Callable[[ReceivesT, MessageContext], bool],
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]]],
MessageHandler[AgentT, ReceivesT, None],
]: ...
@experimental
def event(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]] | None = None,
*,
strict: bool = True,
match: Callable[[ReceivesT, MessageContext], bool] | None = None,
) -> (
Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]]],
MessageHandler[AgentT, ReceivesT, None],
]
| MessageHandler[AgentT, ReceivesT, None]
):
"""Decorator for event message handlers.
Add this decorator to methods in a :class:`RoutedAgent` class that are intended to handle event messages.
These methods must have a specific signature that needs to be followed for it to be valid:
- The method must be an `async` method.
- The method must be decorated with the `@message_handler` decorator.
- The method must have exactly 3 arguments:
1. `self`
2. `message`: The event message to be handled, this must be type-hinted with the message type that it is
intended to handle.
3. `ctx`: A :class:`agent_runtime.core.MessageContext` object.
- The method must return `None`.
Handlers can handle more than one message type by accepting a Union of the message types.
Args:
func: The function to be decorated.
strict: If `True`, the handler will raise an exception if the message type is not in the target types.
If `False`, it will log a warning instead.
match: A function that takes the message and the context as arguments and returns a boolean. This is used for
secondary routing after the message type. For handlers addressing the same message type, the match function
is applied in alphabetical order of the handlers and the first matching handler will be called while the
rest are skipped. If `None`, the first handler in alphabetical order matching the same message type will be
called.
"""
def decorator(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]],
) -> MessageHandler[AgentT, ReceivesT, None]:
type_hints = get_type_hints(func)
if "message" not in type_hints:
raise AssertionError("message parameter not found in function signature")
if "return" not in type_hints:
raise AssertionError("return not found in function signature")
# Get the type of the message parameter
target_types = get_types(type_hints["message"])
if target_types is None:
raise AssertionError("Message type not found. Please provide a type hint for the message parameter.")
return_types = get_types(type_hints["return"])
if return_types is None:
raise AssertionError("Return type not found. Please use `None` as the type hint of the return type.")
# Convert target_types to list and stash
@wraps(func)
async def wrapper(self: AgentT, message: ReceivesT, ctx: MessageContext) -> None:
if type(message) not in target_types:
if strict:
raise CantHandleException(f"Message type {type(message)} not in target types {target_types}")
logger.warning(f"Message type {type(message)} not in target types {target_types}")
return_value = await func(self, message, ctx) # type: ignore
if return_value is not None:
if strict:
raise ValueError(f"Return type {type(return_value)} is not None.")
logger.warning(f"Return type {type(return_value)} is not None. It will be ignored.")
return
wrapper_handler = cast(MessageHandler[AgentT, ReceivesT, None], wrapper)
wrapper_handler.target_types = list(target_types)
wrapper_handler.produces_types = list(return_types)
wrapper_handler.is_message_handler = True
# Wrap the match function with a check on the is_rpc flag.
wrapper_handler.router = lambda _message, _ctx: (not _ctx.is_rpc) and (match(_message, _ctx) if match else True)
return wrapper_handler
if func is None and not callable(func):
return decorator
if callable(func):
return decorator(func)
raise ValueError("Invalid arguments")
@experimental
@overload
def rpc(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]: ...
@experimental
@overload
def rpc(
func: None = None,
*,
match: None = ...,
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
@overload
def rpc(
func: None = None,
*,
match: Callable[[ReceivesT, MessageContext], bool],
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
def rpc(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]] | None = None,
*,
strict: bool = True,
match: Callable[[ReceivesT, MessageContext], bool] | None = None,
) -> (
Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]
| MessageHandler[AgentT, ReceivesT, ProducesT]
):
"""Decorator for RPC message handlers.
Add this decorator to methods in a :class:`RoutedAgent` class that are intended to handle RPC messages.
These methods must have a specific signature that needs to be followed for it to be valid:
- The method must be an `async` method.
- The method must be decorated with the `@message_handler` decorator.
- The method must have exactly 3 arguments:
1. `self`
2. `message`: The message to be handled, this must be type-hinted with the message type that it is intended to
handle.
3. `ctx`: A :class:`agent_runtime.core.MessageContext` object.
- The method must be type hinted with what message types it can return as a response, or it can return `None` if
it does not return anything.
Handlers can handle more than one message type by accepting a Union of the message types. It can also return more
than one message type by returning a Union of the message types.
Args:
func: The function to be decorated.
strict: If `True`, the handler will raise an exception if the message type or return type is not in the target
types. If `False`, it will log a warning instead.
match: A function that takes the message and the context as arguments and returns a boolean. This is used for
secondary routing after the message type. For handlers addressing the same message type, the match function
is applied in alphabetical order of the handlers and the first matching handler will be called while the
rest are skipped. If `None`, the first handler in alphabetical order matching the same message type will be
called.
"""
def decorator(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]:
type_hints = get_type_hints(func)
if "message" not in type_hints:
raise AssertionError("message parameter not found in function signature")
if "return" not in type_hints:
raise AssertionError("return not found in function signature")
# Get the type of the message parameter
target_types = get_types(type_hints["message"])
if target_types is None:
raise AssertionError("Message type not found")
return_types = get_types(type_hints["return"])
if return_types is None:
raise AssertionError("Return type not found")
# Convert target_types to list and stash
@wraps(func)
async def wrapper(self: AgentT, message: ReceivesT, ctx: MessageContext) -> ProducesT:
if type(message) not in target_types:
if strict:
raise CantHandleException(f"Message type {type(message)} not in target types {target_types}")
logger.warning(f"Message type {type(message)} not in target types {target_types}")
return_value = await func(self, message, ctx)
if AnyType not in return_types and type(return_value) not in return_types:
if strict:
raise ValueError(f"Return type {type(return_value)} not in return types {return_types}")
logger.warning(f"Return type {type(return_value)} not in return types {return_types}")
return return_value
wrapper_handler = cast(MessageHandler[AgentT, ReceivesT, ProducesT], wrapper)
wrapper_handler.target_types = list(target_types)
wrapper_handler.produces_types = list(return_types)
wrapper_handler.is_message_handler = True
wrapper_handler.router = lambda _message, _ctx: (_ctx.is_rpc) and (match(_message, _ctx) if match else True)
return wrapper_handler
if func is None and not callable(func):
return decorator
if callable(func):
return decorator(func)
raise ValueError("Invalid arguments")
# endregion
# region RoutedAgent
@experimental
class RoutedAgent(BaseAgent):
"""A base class for agents that route messages to handlers.
Messages are routed based on the type of the message and optional matching functions.
To create a routed agent, subclass this class and add message handlers as methods decorated with
either :func:`event` or :func:`rpc` decorator.
"""
def __init__(self, description: str) -> None:
"""Initialize the routed agent.
Args:
description: The description of the agent.
"""
# Self is already bound to the handlers
self._handlers: DefaultDict[
type[Any],
list[MessageHandler[RoutedAgent, Any, Any]],
] = DefaultDict(list)
handlers = self._discover_handlers()
for message_handler in handlers:
for target_type in message_handler.target_types:
self._handlers[target_type].append(message_handler)
super().__init__(description)
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any | None:
"""Handle a message by routing it to the appropriate message handler.
Do not override this method in subclasses. Instead, add message handlers as methods decorated with
either the :func:`event` or :func:`rpc` decorator.
"""
key_type: type[Any] = type(message) # type: ignore
handlers = self._handlers.get(key_type) # type: ignore
if handlers is not None:
# Iterate over all handlers for this matching message type.
# Call the first handler whose router returns True and then return the result.
for h in handlers:
if h.router(message, ctx):
return await h(self, message, ctx)
return await self.on_unhandled_message(message, ctx) # type: ignore
async def on_unhandled_message(self, message: Any, ctx: MessageContext) -> None:
"""Called when a message is received that does not have a matching message handler.
The default implementation logs an info message.
Args:
message: The message that was not handled.
ctx: The context of the message.
"""
logger.info(f"Unhandled message: {message}")
@classmethod
def _discover_handlers(cls) -> Sequence[MessageHandler[Any, Any, Any]]:
handlers: list[MessageHandler[Any, Any, Any]] = []
for attr in dir(cls):
if callable(getattr(cls, attr, None)):
# Since we are getting it from the class, self is not bound
handler = getattr(cls, attr)
if hasattr(handler, "is_message_handler"):
handlers.append(cast(MessageHandler[Any, Any, Any], handler))
return handlers
@classmethod
def _handles_types(cls) -> list[tuple[type[Any], list[MessageSerializer[Any]]]]:
# TODO(evmattso): handle deduplication
handlers = cls._discover_handlers()
types: list[tuple[type[Any], list[MessageSerializer[Any]]]] = []
types.extend(cls.internal_extra_handles_types)
for handler in handlers:
for t in handler.target_types:
# TODO(evmattso): support different serializers
serializers = try_get_known_serializers_for_type(t)
if len(serializers) == 0:
raise ValueError(f"No serializers found for type {t}.")
types.append((t, try_get_known_serializers_for_type(t)))
return types
# endregion
@@ -0,0 +1,316 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from collections.abc import Sequence
from dataclasses import asdict, dataclass, fields
from typing import Any, ClassVar, Protocol, TypeVar, cast, get_args, get_origin, runtime_checkable
from google.protobuf import any_pb2
from google.protobuf.message import Message
from pydantic import BaseModel
from semantic_kernel.agents.runtime.core.type_helpers import is_union
from semantic_kernel.utils.feature_stage_decorator import experimental
T = TypeVar("T")
@experimental
class MessageSerializer(Protocol[T]):
"""Serializer for messages."""
@property
def data_content_type(self) -> str:
"""Content type of the data being serialized."""
...
@property
def type_name(self) -> str:
"""Type name of the message being serialized."""
...
def deserialize(self, payload: bytes) -> T:
"""Deserialize the payload into a message."""
...
def serialize(self, message: T) -> bytes:
"""Serialize the message into a payload."""
...
@experimental
@runtime_checkable
class IsDataclass(Protocol):
"""Protocol to check if a class is a dataclass."""
# as already noted in comments, checking for this attribute is currently
# the most reliable way to ascertain that something is a dataclass
__dataclass_fields__: ClassVar[dict[str, Any]]
@experimental
def is_dataclass(cls: type[Any]) -> bool:
"""Check if the class is a dataclass."""
return hasattr(cls, "__dataclass_fields__")
@experimental
def has_nested_dataclass(cls: type[IsDataclass]) -> bool:
"""Check if the dataclass has nested dataclasses."""
# iterate fields and check if any of them are dataclasses
return any(is_dataclass(f.type) for f in cls.__dataclass_fields__.values())
@experimental
def contains_a_union(cls: type[IsDataclass]) -> bool:
"""Check if the dataclass contains a union type."""
return any(is_union(f.type) for f in cls.__dataclass_fields__.values())
@experimental
def has_nested_base_model(cls: type[IsDataclass]) -> bool:
"""Check if the dataclass has nested Pydantic BaseModel."""
for f in fields(cls):
field_type = f.type
# Resolve forward references and other annotations
origin = get_origin(field_type)
args = get_args(field_type)
# If the field type is directly a subclass of BaseModel
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
return True
# If the field type is a generic type like List[BaseModel], Tuple[BaseModel, ...], etc.
if origin is not None and args:
for arg in args:
# Recursively check the argument types
if (isinstance(arg, type) and issubclass(arg, BaseModel)) or (
get_origin(arg) is not None and has_nested_base_model_in_type(arg)
):
return True
# Handle Union types
elif args:
for arg in args:
if (isinstance(arg, type) and issubclass(arg, BaseModel)) or (
get_origin(arg) is not None and has_nested_base_model_in_type(arg)
):
return True
return False
@experimental
def has_nested_base_model_in_type(tp: Any) -> bool:
"""Helper function to check if a type or its arguments is a BaseModel subclass."""
origin = get_origin(tp)
args = get_args(tp)
if isinstance(tp, type) and issubclass(tp, BaseModel):
return True
if origin is not None and args:
for arg in args:
if has_nested_base_model_in_type(arg):
return True
return False
DataclassT = TypeVar("DataclassT", bound=IsDataclass)
JSON_DATA_CONTENT_TYPE = "application/json"
"""JSON data content type"""
# TODO(evmattso): what's the correct content type? There seems to be some disagreement over what it should be
PROTOBUF_DATA_CONTENT_TYPE = "application/x-protobuf"
"""Protobuf data content type"""
@experimental
class DataclassJsonMessageSerializer(MessageSerializer[DataclassT]):
"""Serializer for dataclass messages."""
def __init__(self, cls: type[DataclassT]) -> None:
"""Initialize the serializer with a dataclass type."""
if contains_a_union(cls):
raise ValueError("Dataclass has a union type, which is not supported. To use a union, use a Pydantic model")
if has_nested_dataclass(cls) or has_nested_base_model(cls):
raise ValueError(
"Dataclass has nested dataclasses or base models, which are not supported. To use nested types, "
"use a Pydantic model"
)
self.cls = cls
@property
def data_content_type(self) -> str:
"""Return the data content type."""
return JSON_DATA_CONTENT_TYPE
@property
def type_name(self) -> str:
"""Return the type name."""
return _type_name(self.cls)
def deserialize(self, payload: bytes) -> DataclassT:
"""Deserialize the payload into a dataclass message."""
message_str = payload.decode("utf-8")
return self.cls(**json.loads(message_str))
def serialize(self, message: DataclassT) -> bytes:
"""Serialize the dataclass message into a payload."""
return json.dumps(asdict(message)).encode("utf-8")
PydanticT = TypeVar("PydanticT", bound=BaseModel)
@experimental
class PydanticJsonMessageSerializer(MessageSerializer[PydanticT]):
"""Serializer for Pydantic messages."""
def __init__(self, cls: type[PydanticT]) -> None:
"""Initialize the serializer with a Pydantic model type."""
self.cls = cls
@property
def data_content_type(self) -> str:
"""Return the data content type."""
return JSON_DATA_CONTENT_TYPE
@property
def type_name(self) -> str:
"""Return the type name."""
return _type_name(self.cls)
def deserialize(self, payload: bytes) -> PydanticT:
"""Deserialize the payload into a Pydantic model message."""
message_str = payload.decode("utf-8")
return self.cls.model_validate_json(message_str)
def serialize(self, message: PydanticT) -> bytes:
"""Serialize the Pydantic model message into a payload."""
return message.model_dump_json().encode("utf-8")
ProtobufT = TypeVar("ProtobufT", bound=Message)
# This class serializes to and from a google.protobuf.Any message that has been serialized to a string
@experimental
class ProtobufMessageSerializer(MessageSerializer[ProtobufT]):
"""Serializer for Protobuf messages."""
def __init__(self, cls: type[ProtobufT]) -> None:
"""Initialize the serializer with a Protobuf message type."""
self.cls = cls
@property
def data_content_type(self) -> str:
"""Return the data content type."""
return PROTOBUF_DATA_CONTENT_TYPE
@property
def type_name(self) -> str:
"""Return the type name."""
return _type_name(self.cls)
def deserialize(self, payload: bytes) -> ProtobufT:
"""Deserialize the payload into a Protobuf message."""
# Parse payload into a proto any
any_proto = any_pb2.Any()
any_proto.ParseFromString(payload)
destination_message = self.cls()
if not any_proto.Unpack(destination_message): # type: ignore
raise ValueError(f"Failed to unpack payload into {self.cls}")
return destination_message
def serialize(self, message: ProtobufT) -> bytes:
"""Serialize the Protobuf message into a payload."""
any_proto = any_pb2.Any()
any_proto.Pack(message) # type: ignore
return any_proto.SerializeToString()
@experimental
@dataclass
class UnknownPayload:
"""Class to represent an unknown payload."""
type_name: str
data_content_type: str
payload: bytes
def _type_name(cls: type[Any] | Any) -> str:
# If cls is a protobuf, then we need to determine the descriptor
if isinstance(cls, type):
if issubclass(cls, Message):
return cast(str, cls.DESCRIPTOR.full_name) # type: ignore
elif isinstance(cls, Message):
return cast(str, cls.DESCRIPTOR.full_name)
if isinstance(cls, type):
return cls.__name__
return cast(str, cls.__class__.__name__)
V = TypeVar("V")
@experimental
def try_get_known_serializers_for_type(cls: type[Any]) -> list[MessageSerializer[Any]]:
"""Try to get known serializers for a type."""
serializers: list[MessageSerializer[Any]] = []
if issubclass(cls, BaseModel):
serializers.append(PydanticJsonMessageSerializer(cls))
elif is_dataclass(cls):
serializers.append(DataclassJsonMessageSerializer(cls))
elif issubclass(cls, Message):
serializers.append(ProtobufMessageSerializer(cls))
return serializers
@experimental
class SerializationRegistry:
"""Serialization registry for messages."""
def __init__(self) -> None:
"""Initialize the serialization registry."""
# type_name, data_content_type -> serializer
self._serializers: dict[tuple[str, str], MessageSerializer[Any]] = {}
def add_serializer(self, serializer: MessageSerializer[Any] | Sequence[MessageSerializer[Any]]) -> None:
"""Add a new serializer to the registry."""
if isinstance(serializer, Sequence):
for c in serializer:
self.add_serializer(c)
return
self._serializers[serializer.type_name, serializer.data_content_type] = serializer
def deserialize(self, payload: bytes, *, type_name: str, data_content_type: str) -> Any:
"""Deserialize a payload into a message."""
serializer = self._serializers.get((type_name, data_content_type))
if serializer is None:
return UnknownPayload(type_name, data_content_type, payload)
return serializer.deserialize(payload)
def serialize(self, message: Any, *, type_name: str, data_content_type: str) -> bytes:
"""Serialize a message into a payload."""
serializer = self._serializers.get((type_name, data_content_type))
if serializer is None:
raise ValueError(f"Unknown type {type_name} with content type {data_content_type}")
return serializer.serialize(message)
def is_registered(self, type_name: str, data_content_type: str) -> bool:
"""Check if a type is registered in the registry."""
return (type_name, data_content_type) in self._serializers
def type_name(self, message: Any) -> str:
"""Get the type name of a message."""
return _type_name(message)
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable
from typing import Protocol, runtime_checkable
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class Subscription(Protocol):
"""Subscriptions define the topics that an agent is interested in."""
@property
def id(self) -> str:
"""Get the ID of the subscription.
Implementations should return a unique ID for the subscription. Usually this is a UUID.
Returns:
str: ID of the subscription.
"""
...
def __eq__(self, other: object) -> bool:
"""Check if two subscriptions are equal.
Args:
other (object): Other subscription to compare against.
Returns:
bool: True if the subscriptions are equal, False otherwise.
"""
if not isinstance(other, Subscription):
return False
return self.id == other.id
def is_match(self, topic_id: TopicId) -> bool:
"""Check if a given topic_id matches the subscription.
Args:
topic_id (TopicId): TopicId to check.
Returns:
bool: True if the topic_id matches the subscription, False otherwise.
"""
...
def map_to_agent(self, topic_id: TopicId) -> AgentId:
"""Map a topic_id to an agent. Should only be called if `is_match` returns True for the given topic_id.
Args:
topic_id (TopicId): TopicId to map.
Returns:
AgentId: ID of the agent that should handle the topic_id.
Raises:
CantHandleException: If the subscription cannot handle the topic_id.
"""
...
# Helper alias to represent the lambdas used to define subscriptions
UnboundSubscription = Callable[[], list[Subscription] | Awaitable[list[Subscription]]]
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
from .propagation import (
EnvelopeMetadata,
TelemetryMetadataContainer,
get_telemetry_envelope_metadata,
get_telemetry_grpc_metadata,
)
from .tracing import TraceHelper
from .tracing_config import MessageRuntimeTracingConfig
__all__ = [
"EnvelopeMetadata",
"MessageRuntimeTracingConfig",
"TelemetryMetadataContainer",
"TraceHelper",
"get_telemetry_envelope_metadata",
"get_telemetry_grpc_metadata",
]
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
NAMESPACE = "agent_runtime"
@@ -0,0 +1,132 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Optional
from opentelemetry.context import Context
from opentelemetry.propagate import extract
from opentelemetry.trace import Link, get_current_span
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@dataclass(kw_only=True)
class EnvelopeMetadata:
"""Metadata for an envelope."""
traceparent: str | None = None
tracestate: str | None = None
links: Sequence[Link] | None = None
def _get_carrier_for_envelope_metadata(envelope_metadata: EnvelopeMetadata) -> dict[str, str]:
carrier: dict[str, str] = {}
if envelope_metadata.traceparent is not None:
carrier["traceparent"] = envelope_metadata.traceparent
if envelope_metadata.tracestate is not None:
carrier["tracestate"] = envelope_metadata.tracestate
return carrier
@experimental
def get_telemetry_envelope_metadata() -> EnvelopeMetadata:
"""Retrieves the telemetry envelope metadata.
Returns:
EnvelopeMetadata: The envelope metadata containing the traceparent and tracestate.
"""
carrier: dict[str, str] = {}
TraceContextTextMapPropagator().inject(carrier)
return EnvelopeMetadata(
traceparent=carrier.get("traceparent"),
tracestate=carrier.get("tracestate"),
)
def _get_carrier_for_remote_call_metadata(remote_call_metadata: Mapping[str, str]) -> dict[str, str]:
carrier: dict[str, str] = {}
traceparent = remote_call_metadata.get("traceparent")
tracestate = remote_call_metadata.get("tracestate")
if traceparent:
carrier["traceparent"] = traceparent
if tracestate:
carrier["tracestate"] = tracestate
return carrier
@experimental
def get_telemetry_grpc_metadata(existingMetadata: Mapping[str, str] | None = None) -> dict[str, str]:
"""Retrieves the telemetry gRPC metadata.
Args:
existingMetadata (Optional[Mapping[str, str]]): The existing metadata to include in the gRPC metadata.
Returns:
Mapping[str, str]: The gRPC metadata containing the traceparent and tracestate.
"""
carrier: dict[str, str] = {}
TraceContextTextMapPropagator().inject(carrier)
traceparent = carrier.get("traceparent")
tracestate = carrier.get("tracestate")
metadata: dict[str, str] = {}
if existingMetadata is not None:
for key, value in existingMetadata.items():
metadata[key] = value
if traceparent is not None:
metadata["traceparent"] = traceparent
if tracestate is not None:
metadata["tracestate"] = tracestate
return metadata
TelemetryMetadataContainer = Optional[EnvelopeMetadata] | Mapping[str, str]
@experimental
def get_telemetry_context(metadata: TelemetryMetadataContainer) -> Context:
"""Retrieves the telemetry context from the given metadata.
Args:
metadata (Optional[EnvelopeMetadata]): The metadata containing the telemetry context.
Returns:
Context: The telemetry context extracted from the metadata, or an empty context if the metadata is None.
"""
if metadata is None:
return Context()
if isinstance(metadata, EnvelopeMetadata):
return extract(_get_carrier_for_envelope_metadata(metadata))
if hasattr(metadata, "__getitem__"):
return extract(_get_carrier_for_remote_call_metadata(metadata))
raise ValueError(f"Unknown metadata type: {type(metadata)}")
@experimental
def get_telemetry_links(
metadata: TelemetryMetadataContainer,
) -> Sequence[Link] | None:
"""Retrieves the telemetry links from the given metadata.
Args:
metadata (Optional[EnvelopeMetadata]): The metadata containing the telemetry links.
Returns:
Optional[Sequence[Link]]: The telemetry links extracted from the metadata, or None if there are no links.
"""
if metadata is None:
return None
if isinstance(metadata, EnvelopeMetadata):
context = extract(_get_carrier_for_envelope_metadata(metadata))
elif hasattr(metadata, "__getitem__"):
context = extract(_get_carrier_for_remote_call_metadata(metadata))
else:
return None
# Retrieve the extracted SpanContext from the context.
linked_span = get_current_span(context)
# Use the linked span to get the SpanContext.
span_context = linked_span.get_span_context()
# Create a Link object using the SpanContext.
return [Link(span_context)]
@@ -0,0 +1,102 @@
# Copyright (c) Microsoft. All rights reserved.
import contextlib
from collections.abc import Iterator
from typing import Generic
from opentelemetry.trace import NoOpTracerProvider, Span, SpanKind, TracerProvider, get_tracer_provider
from opentelemetry.util import types
from semantic_kernel.agents.runtime.core.telemetry.propagation import TelemetryMetadataContainer, get_telemetry_links
from semantic_kernel.agents.runtime.core.telemetry.tracing_config import (
Destination,
ExtraAttributes,
Operation,
TracingConfig,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class TraceHelper(Generic[Operation, Destination, ExtraAttributes]):
"""TraceHelper is a utility class to assist with tracing operations using OpenTelemetry.
This class provides a context manager `trace_block` to create and manage spans for tracing operations,
following semantic conventions and supporting nested spans through metadata contexts.
"""
def __init__(
self,
tracer_provider: TracerProvider | None,
instrumentation_builder_config: TracingConfig[Operation, Destination, ExtraAttributes],
) -> None:
"""Initialize the TraceHelper with a tracer provider and instrumentation builder config."""
# Evaluate in order: first try tracer_provider param, then get_tracer_provider(), finally fallback to NoOp
# This allows for nested tracing with a default tracer provided by the user
self.tracer_provider = tracer_provider or get_tracer_provider() or NoOpTracerProvider()
self.tracer = self.tracer_provider.get_tracer(f"agent_runtime {instrumentation_builder_config.name}")
self.instrumentation_builder_config = instrumentation_builder_config
@contextlib.contextmanager
def trace_block(
self,
operation: Operation,
destination: Destination,
parent: TelemetryMetadataContainer | None,
*,
extraAttributes: ExtraAttributes | None = None,
kind: SpanKind | None = None,
attributes: types.Attributes | None = None,
start_time: int | None = None,
record_exception: bool = True,
set_status_on_exception: bool = True,
end_on_exit: bool = True,
) -> Iterator[Span]:
"""Thin wrapper on top of start_as_current_span.
1. It helps us follow semantic conventions
2. It helps us get contexts from metadata so we can get nested spans
Args:
operation (MessagingOperation): The messaging operation being performed.
destination (MessagingDestination): The messaging destination being used.
parent (Optional[TelemetryMetadataContainer]): The parent telemetry metadata context
kind (SpanKind, optional): The kind of span. If not provided, it maps to PRODUCER or CONSUMER depending
on the operation.
extraAttributes (ExtraAttributes, optional): Additional defined attributes for the span. Defaults to None.
attributes (Optional[types.Attributes], optional): Additional non-defined attributes for the span.
Defaults to None.
start_time (Optional[int], optional): The start time of the span. Defaults to None.
record_exception (bool, optional): Whether to record exceptions. Defaults to True.
set_status_on_exception (bool, optional): Whether to set the status on exception. Defaults to True.
end_on_exit (bool, optional): Whether to end the span on exit. Defaults to True.
Yields:
Iterator[Span]: The span object.
"""
span_name = self.instrumentation_builder_config.get_span_name(operation, destination)
span_kind = kind or self.instrumentation_builder_config.get_span_kind(operation)
context = None # TODO(evmattso): we may need to remove other code for using custom context.
links = get_telemetry_links(parent) if parent else None
attributes_with_defaults: dict[str, types.AttributeValue] = {}
for key, value in (attributes or {}).items():
attributes_with_defaults[key] = value
instrumentation_attributes = self.instrumentation_builder_config.build_attributes(
operation, destination, extraAttributes
)
for key, value in instrumentation_attributes.items():
attributes_with_defaults[key] = value
with self.tracer.start_as_current_span(
span_name,
context,
span_kind,
attributes_with_defaults,
links,
start_time,
record_exception,
set_status_on_exception,
end_on_exit,
) as span:
yield span
@@ -0,0 +1,201 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from abc import ABC, abstractmethod
from typing import Generic, Literal, TypedDict, TypeVar, Union
from opentelemetry.trace import SpanKind
from opentelemetry.util import types
from typing_extensions import NotRequired
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.telemetry.constants import NAMESPACE
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger("agent_runtime")
event_logger = logging.getLogger("agent_runtime.events")
Operation = TypeVar("Operation", bound=str)
Destination = TypeVar("Destination")
ExtraAttributes = TypeVar("ExtraAttributes")
@experimental
class TracingConfig(ABC, Generic[Operation, Destination, ExtraAttributes]):
"""A protocol that defines the configuration for instrumentation.
This protocol specifies the required properties and methods that any
instrumentation configuration class must implement. It includes a
property to get the name of the module being instrumented and a method
to build attributes for the instrumentation configuration.
"""
@property
@abstractmethod
def name(self) -> str:
"""Gets the name of the module being instrumented."""
...
@abstractmethod
def build_attributes(
self,
operation: Operation,
destination: Destination,
extraAttributes: ExtraAttributes | None,
) -> dict[str, types.AttributeValue]:
"""Builds the attributes for the instrumentation configuration.
Returns:
Dict[str, str]: The attributes for the instrumentation configuration.
"""
...
@abstractmethod
def get_span_name(
self,
operation: Operation,
destination: Destination,
) -> str:
"""Returns the span name based on the given operation and destination.
Parameters:
operation (MessagingOperation): The messaging operation.
destination (Optional[MessagingDestination]): The messaging destination.
Returns:
str: The span name.
"""
...
@abstractmethod
def get_span_kind(
self,
operation: Operation,
) -> SpanKind:
"""Determines the span kind based on the given messaging operation.
Parameters:
operation (MessagingOperation): The messaging operation.
Returns:
SpanKind: The span kind based on the messaging operation.
"""
@experimental
class ExtraMessageRuntimeAttributes(TypedDict):
"""A dictionary of extra attributes for message runtime instrumentation."""
message_size: NotRequired[int]
message_type: NotRequired[str]
MessagingDestination = Union[AgentId, TopicId, str, None]
MessagingOperation = Literal["create", "send", "publish", "receive", "intercept", "process", "ack"]
@experimental
class MessageRuntimeTracingConfig(
TracingConfig[MessagingOperation, MessagingDestination, ExtraMessageRuntimeAttributes]
):
"""A class that defines the configuration for message runtime instrumentation.
This class implements the TracingConfig protocol and provides
the name of the module being instrumented and the attributes for the
instrumentation configuration.
"""
def __init__(self, runtime_name: str) -> None:
"""Initialize the MessageRuntimeTracingConfig with the runtime name."""
self._runtime_name = runtime_name
@property
def name(self) -> str:
"""Get the name of the module being instrumented."""
return self._runtime_name
def build_attributes(
self,
operation: MessagingOperation,
destination: MessagingDestination,
extraAttributes: ExtraMessageRuntimeAttributes | None,
) -> dict[str, types.AttributeValue]:
"""Build the attributes for the instrumentation configuration."""
attrs: dict[str, types.AttributeValue] = {
"messaging.operation": self._get_operation_type(operation),
"messaging.destination": self._get_destination_str(destination),
}
if extraAttributes:
# TODO(evmattso): Make this more pythonic?
if "message_size" in extraAttributes:
attrs["messaging.message.envelope.size"] = extraAttributes["message_size"]
if "message_type" in extraAttributes:
attrs["messaging.message.type"] = extraAttributes["message_type"]
return attrs
def get_span_name(
self,
operation: MessagingOperation,
destination: MessagingDestination,
) -> str:
"""Returns the span name based on the given operation and destination.
Semantic Conventions - https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name
Parameters:
operation (MessagingOperation): The messaging operation.
destination (Optional[MessagingDestination]): The messaging destination.
Returns:
str: The span name.
"""
span_parts: list[str] = [operation]
destination_str = self._get_destination_str(destination)
if destination_str:
span_parts.append(destination_str)
span_name = " ".join(span_parts)
return f"{NAMESPACE} {span_name}"
def get_span_kind(
self,
operation: MessagingOperation,
) -> SpanKind:
"""Determines the span kind based on the given messaging operation.
Semantic Conventions - https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-kind
Parameters:
operation (MessagingOperation): The messaging operation.
Returns:
SpanKind: The span kind based on the messaging operation.
"""
if operation in ["create", "send", "publish"]:
return SpanKind.PRODUCER
if operation in ["receive", "intercept", "process", "ack"]:
return SpanKind.CONSUMER
return SpanKind.CLIENT
# TODO(evmattso): Use stringified convention
def _get_destination_str(self, destination: MessagingDestination) -> str:
if isinstance(destination, AgentId):
return f"{destination.type}.({destination.key})-A"
if isinstance(destination, TopicId):
return f"{destination.type}.({destination.source})-T"
if isinstance(destination, str):
return destination
if destination is None:
return ""
raise ValueError(f"Unknown destination type: {type(destination)}")
def _get_operation_type(self, operation: MessagingOperation) -> str:
if operation in ["send", "publish"]:
return "publish"
if operation in ["create"]:
return "create"
if operation in ["receive", "intercept", "ack"]:
return "receive"
if operation in ["process"]:
return "process"
return "Unknown"
@@ -0,0 +1,58 @@
# Copyright (c) Microsoft. All rights reserved.
import re
from dataclasses import dataclass
from typing_extensions import Self
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
def is_valid_topic_type(value: str) -> bool:
"""Check if the given value is a valid topic type."""
return bool(re.match(r"^[\w\-\.\:\=]+\Z", value))
@experimental
@dataclass(eq=True, frozen=True)
class TopicId:
"""TopicId defines the scope of a broadcast message.
In essence, agent runtime implements a publish-subscribe model through its broadcast API: when publishing a message,
the topic must be specified.
See here for more information: :ref:`topic_and_subscription_topic`
"""
type: str
"""Type of the event that this topic_id contains. Adhere's to the cloud event spec.
Must match the pattern: ^[\\w\\-\\.\\:\\=]+\\Z
Learn more here: https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#type
"""
source: str
"""Identifies the context in which an event happened. Adhere's to the cloud event spec.
Learn more here: https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#source-1
"""
def __post_init__(self) -> None:
"""Validate the topic type and source."""
if is_valid_topic_type(self.type) is False:
raise ValueError(f"Invalid topic type: {self.type}. Must match the pattern: ^[\\w\\-\\.\\:\\=]+\\Z")
def __str__(self) -> str:
"""Convert the TopicId to a string."""
return f"{self.type}/{self.source}"
@classmethod
def from_str(cls, topic_id: str) -> Self:
"""Convert a string of the format ``type/source`` into a TopicId."""
items = topic_id.split("/", maxsplit=1)
if len(items) != 2:
raise ValueError(f"Invalid topic id: {topic_id}")
type, source = items[0], items[1]
return cls(type, source)
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from types import NoneType, UnionType
from typing import Any, Optional, Union, get_args, get_origin
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
def is_union(t: object) -> bool:
"""Check if the type is a Union or UnionType."""
origin = get_origin(t)
return origin is Union or origin is UnionType
@experimental
def is_optional(t: object) -> bool:
"""Check if the type is an Optional."""
origin = get_origin(t)
return origin is Optional
# Special type to avoid the 3.10 vs 3.11+ difference of typing._SpecialForm vs typing.Any
@experimental
class AnyType:
"""Special type to represent Any."""
pass
@experimental
def get_types(t: object) -> Sequence[type[Any]] | None:
"""Get the types from a Union or Optional type."""
if is_union(t):
return get_args(t)
if is_optional(t):
return tuple([*list(get_args(t)), NoneType])
if t is Any:
return (AnyType,)
if isinstance(t, type):
return (t,)
if isinstance(t, NoneType):
return (NoneType,)
return None
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import re
from semantic_kernel.utils.feature_stage_decorator import experimental
_AGENT_TYPE_REGEX = re.compile(r"^[\w\-\.]+\Z")
@experimental
def is_valid_agent_type(value: str) -> bool:
"""Check if the agent type is valid."""
return bool(_AGENT_TYPE_REGEX.match(value))