chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run

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,33 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.runtime.core.agent import Agent
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.base_agent import BaseAgent
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.routed_agent import MessageHandler, RoutedAgent, message_handler
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.default_subscription import DefaultSubscription
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
__all__ = [
"Agent",
"AgentId",
"AgentMetadata",
"BaseAgent",
"CoreAgentId",
"CoreAgentMetadata",
"CoreRuntime",
"DefaultSubscription",
"InProcessRuntime",
"MessageContext",
"MessageHandler",
"RoutedAgent",
"Subscription",
"TopicId",
"TypeSubscription",
"message_handler",
]
@@ -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))
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, ClassVar
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class AgentInstantiationContext:
"""A static class that provides context for agent instantiation.
This static class can be used to access the current runtime and agent ID
during agent instantiation -- inside the factory function or the agent's
class constructor.
"""
def __init__(self) -> None:
"""Instantiate the AgentInstantiationContext class."""
raise RuntimeError(
"AgentInstantiationContext cannot be instantiated. It is a static class that provides context management "
"for agent instantiation."
)
_AGENT_INSTANTIATION_CONTEXT_VAR: ClassVar[ContextVar[tuple[CoreRuntime, AgentId]]] = ContextVar(
"_AGENT_INSTANTIATION_CONTEXT_VAR"
)
@classmethod
@contextmanager
def populate_context(cls, ctx: tuple[CoreRuntime, AgentId]) -> Generator[None, Any, None]:
"""Populate the context with the current runtime and agent ID."""
token = AgentInstantiationContext._AGENT_INSTANTIATION_CONTEXT_VAR.set(ctx)
try:
yield
finally:
AgentInstantiationContext._AGENT_INSTANTIATION_CONTEXT_VAR.reset(token)
@classmethod
def current_runtime(cls) -> CoreRuntime:
"""Get the current runtime."""
try:
return cls._AGENT_INSTANTIATION_CONTEXT_VAR.get()[0]
except LookupError as e:
raise RuntimeError(
"AgentInstantiationContext.runtime() must be called within an instantiation context such as when the "
"AgentRuntime is instantiating an agent. Mostly likely this was caused by directly instantiating an "
"agent instead of using the AgentRuntime to do so."
) from e
@classmethod
def current_agent_id(cls) -> AgentId:
"""Get the current agent ID."""
try:
return cls._AGENT_INSTANTIATION_CONTEXT_VAR.get()[1]
except LookupError as e:
raise RuntimeError(
"AgentInstantiationContext.agent_id() must be called within an instantiation context such as when the "
"AgentRuntime is instantiating an agent. Mostly likely this was caused by directly instantiating an "
"agent instead of using the AgentRuntime to do so."
) from e
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from typing import TypeVar, overload
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent, subscription_factory
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
from semantic_kernel.agents.runtime.in_process.subscription_context import SubscriptionInstantiationContext
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DefaultSubscription(TypeSubscription):
"""The default subscription is designed to be a default for applications that only need global scope for agents.
This topic by default uses the "default" topic type and attempts to detect the agent type to use based on the
instantiation context.
Args:
topic_type (str, optional): The topic type to subscribe to. Defaults to "default".
agent_type (str, optional): The agent type to use for the subscription. Defaults to None, in which case it
will attempt to detect the agent type based on the instantiation context.
"""
def __init__(self, topic_type: str = "default", agent_type: str | AgentType | None = None):
"""Initialize the DefaultSubscription."""
if agent_type is None:
try:
agent_type = SubscriptionInstantiationContext.agent_type().type
except RuntimeError as e:
raise CantHandleException(
"If agent_type is not specified DefaultSubscription must be created within the subscription "
"callback in AgentRuntime.register"
) from e
super().__init__(topic_type, agent_type)
BaseAgentType = TypeVar("BaseAgentType", bound="BaseAgent")
@overload
def default_subscription() -> Callable[[type[BaseAgentType]], type[BaseAgentType]]: ...
@overload
def default_subscription(cls: type[BaseAgentType]) -> type[BaseAgentType]: ...
@experimental
def default_subscription(
cls: type[BaseAgentType] | None = None,
) -> Callable[[type[BaseAgentType]], type[BaseAgentType]] | type[BaseAgentType]:
"""Create a default subscription."""
if cls is None:
return subscription_factory(lambda: [DefaultSubscription()])
return subscription_factory(lambda: [DefaultSubscription()])(cls)
@experimental
def type_subscription(topic_type: str) -> Callable[[type[BaseAgentType]], type[BaseAgentType]]:
"""Create a type subscription for the given topic type."""
return subscription_factory(lambda: [DefaultSubscription(topic_type=topic_type)])
@@ -0,0 +1,31 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.message_handler_context import MessageHandlerContext
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DefaultTopicId(TopicId):
"""DefaultTopicId provides a sensible default for the topic_id and source fields of a TopicId.
If created in the context of a message handler, the source will be set to the agent_id of the message handler,
otherwise it will be set to "default".
Args:
type (str, optional): Topic type to publish message to. Defaults to "default".
source (str | None, optional): Topic source to publish message to. If None, the source will be set to the
agent_id of the message handler if in the context of a message handler, otherwise it will be set to
"default". Defaults to None.
"""
def __init__(self, type: str = "default", source: str | None = None) -> None:
"""Initialize the DefaultTopicId."""
if source is None:
try:
source = MessageHandlerContext.agent_id().key
# If we aren't in the context of a message handler, we use the default source
except RuntimeError:
source = "default"
super().__init__(type, source)
@@ -0,0 +1,854 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import logging
import sys
import uuid
import warnings
from asyncio import CancelledError, Future, Queue, Task
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, ParamSpec, TypeVar, cast
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 13):
from asyncio import Queue, QueueShutDown
else:
from .queue import Queue, QueueShutDown # type: ignore
from opentelemetry.trace import TracerProvider
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata
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.exceptions import MessageDroppedException
from semantic_kernel.agents.runtime.core.intervention import DropMessage, InterventionHandler
from semantic_kernel.agents.runtime.core.logging import (
AgentConstructionExceptionEvent,
DeliveryStage,
MessageDroppedEvent,
MessageEvent,
MessageHandlerExceptionEvent,
MessageKind,
)
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.serialization import (
JSON_DATA_CONTENT_TYPE,
MessageSerializer,
SerializationRegistry,
)
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.telemetry import (
EnvelopeMetadata,
MessageRuntimeTracingConfig,
TraceHelper,
get_telemetry_envelope_metadata,
)
from semantic_kernel.agents.runtime.core.topic import TopicId
from .agent_instantiation_context import AgentInstantiationContext
from .message_handler_context import MessageHandlerContext
from .runtime_impl_helpers import SubscriptionManager, get_impl
logger = logging.getLogger("in_process_runtime")
event_logger = logging.getLogger("in_process_runtime.events")
# We use a type parameter in some functions which shadows the built-in `type` function.
# This is a workaround to avoid shadowing the built-in `type` function.
type_func_alias = type
@experimental
@dataclass(kw_only=True)
class PublishMessageEnvelope:
"""A message envelope for publishing messages to all agents that can handle the message of the type T."""
message: Any
cancellation_token: CancellationToken
sender: AgentId | None
topic_id: TopicId
metadata: EnvelopeMetadata | None = None
message_id: str
@experimental
@dataclass(kw_only=True)
class SendMessageEnvelope:
"""A message envelope for sending a message to a specific agent that can handle the message of the type T."""
message: Any
sender: AgentId | None
recipient: AgentId
future: Future[Any]
cancellation_token: CancellationToken
metadata: EnvelopeMetadata | None = None
message_id: str
@experimental
@dataclass(kw_only=True)
class ResponseMessageEnvelope:
"""A message envelope for sending a response to a message."""
message: Any
future: Future[Any]
sender: AgentId
recipient: AgentId | None
metadata: EnvelopeMetadata | None = None
P = ParamSpec("P")
T = TypeVar("T", bound=Agent)
@experimental
class RunContext:
"""A context for the runtime to run in a background task."""
def __init__(self, runtime: "InProcessRuntime") -> None:
"""Initialize the run context."""
self._runtime = runtime
self._run_task = asyncio.create_task(self._run())
self._stopped = asyncio.Event()
async def _run(self) -> None:
while True:
if self._stopped.is_set():
return
await self._runtime._process_next() # type: ignore
async def stop(self) -> None:
"""Stop the runtime message processing loop immediately."""
self._stopped.set()
self._runtime._message_queue.shutdown(immediate=True) # type: ignore
await self._run_task
async def stop_when_idle(self) -> None:
"""Stop the runtime message processing loop when there are no messages in the queue."""
await self._runtime._message_queue.join() # type: ignore
self._stopped.set()
self._runtime._message_queue.shutdown(immediate=True) # type: ignore
await self._run_task
async def stop_when(self, condition: Callable[[], bool], check_period: float = 1.0) -> None:
"""Stop the runtime message processing loop when the condition is met."""
async def check_condition() -> None:
while not condition():
await asyncio.sleep(check_period)
await self.stop()
await asyncio.create_task(check_condition())
def _warn_if_none(value: Any, handler_name: str) -> None:
"""Utility function to check if the intervention handler returned None and issue a warning.
Args:
value: The return value to check
handler_name: Name of the intervention handler method for the warning message
"""
if value is None:
warnings.warn(
f"Intervention handler {handler_name} returned None. This might be unintentional. "
"Consider returning the original message or DropMessage explicitly.",
RuntimeWarning,
stacklevel=2,
)
@experimental
class InProcessRuntime(CoreRuntime):
"""A in-process runtime that processes all messages using a single asyncio queue.
Messages are delivered in the order they are received, and the runtime processes
each message in a separate asyncio task concurrently.
"""
def __init__(
self,
*,
intervention_handlers: list[InterventionHandler] | None = None,
tracer_provider: TracerProvider | None = None,
ignore_unhandled_exceptions: bool = True,
) -> None:
"""Initialize the runtime."""
self._tracer_helper = TraceHelper(tracer_provider, MessageRuntimeTracingConfig("InProcessRuntime"))
self._message_queue: Queue[PublishMessageEnvelope | SendMessageEnvelope | ResponseMessageEnvelope] = Queue()
# (namespace, type) -> List[AgentId]
self._agent_factories: dict[
str, Callable[[], Agent | Awaitable[Agent]] | Callable[[CoreRuntime, AgentId], Agent | Awaitable[Agent]]
] = {}
self._instantiated_agents: dict[AgentId, Agent] = {}
self._intervention_handlers = intervention_handlers
self._background_tasks: set[Task[Any]] = set()
self._subscription_manager = SubscriptionManager()
self._run_context: RunContext | None = None
self._serialization_registry = SerializationRegistry()
self._ignore_unhandled_handler_exceptions = ignore_unhandled_exceptions
self._background_exception: BaseException | None = None
@property
def unprocessed_messages_count(
self,
) -> int:
"""Get the number of unprocessed messages in the queue."""
return self._message_queue.qsize()
@property
def _known_agent_names(self) -> set[str]:
return set(self._agent_factories.keys())
# Returns the response of the message
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."""
if cancellation_token is None:
cancellation_token = CancellationToken()
if message_id is None:
message_id = str(uuid.uuid4())
event_logger.info(
MessageEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=recipient,
kind=MessageKind.DIRECT,
delivery_stage=DeliveryStage.SEND,
)
)
with self._tracer_helper.trace_block(
"create",
recipient,
parent=None,
extraAttributes={"message_type": type(message).__name__},
):
future = asyncio.get_event_loop().create_future()
if recipient.type not in self._known_agent_names:
future.set_exception(Exception("Recipient not found"))
content = message.__dict__ if hasattr(message, "__dict__") else message
logger.info(f"Sending message of type {type(message).__name__} to {recipient.type}: {content}")
await self._message_queue.put(
SendMessageEnvelope(
message=message,
recipient=recipient,
future=future,
cancellation_token=cancellation_token,
sender=sender,
metadata=get_telemetry_envelope_metadata(),
message_id=message_id,
)
)
cancellation_token.link_future(future)
return await future
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 that are subscribed to the topic."""
with self._tracer_helper.trace_block(
"create",
topic_id,
parent=None,
extraAttributes={"message_type": type(message).__name__},
):
if cancellation_token is None:
cancellation_token = CancellationToken()
content = message.__dict__ if hasattr(message, "__dict__") else message
logger.info(f"Publishing message of type {type(message).__name__} to all subscribers: {content}")
if message_id is None:
message_id = str(uuid.uuid4())
event_logger.info(
MessageEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=topic_id,
kind=MessageKind.PUBLISH,
delivery_stage=DeliveryStage.SEND,
)
)
await self._message_queue.put(
PublishMessageEnvelope(
message=message,
cancellation_token=cancellation_token,
sender=sender,
topic_id=topic_id,
metadata=get_telemetry_envelope_metadata(),
message_id=message_id,
)
)
async def save_state(self) -> Mapping[str, Any]:
"""Save the state of all instantiated agents.
This method calls the :meth:`~agent_runtime.BaseAgent.save_state` method on each agent and returns a dictionary
mapping agent IDs to their state.
.. note::
This method does not currently save the subscription state. We will add this in the future.
Returns:
A dictionary mapping agent IDs to their state.
"""
state: dict[str, dict[str, Any]] = {}
for agent_id in self._instantiated_agents:
state[str(agent_id)] = dict(await (await self._get_agent(agent_id)).save_state())
return state
async def load_state(self, state: Mapping[str, Any]) -> None:
"""Load the state of all instantiated agents.
This method calls the :meth:`~agent_runtime.BaseAgent.load_state` method on each agent with the state
provided in the dictionary. The keys of the dictionary are the agent IDs, and the values are the state
dictionaries returned by the :meth:`~agent_runtime.BaseAgent.save_state` method.
.. note::
This method does not currently load the subscription state. We will add this in the future.
"""
for agent_id_str in state:
agent_id = CoreAgentId.from_str(agent_id_str)
if agent_id.type in self._known_agent_names:
await (await self._get_agent(agent_id)).load_state(state[str(agent_id)])
async def _process_send(self, message_envelope: SendMessageEnvelope) -> None:
with self._tracer_helper.trace_block("send", message_envelope.recipient, parent=message_envelope.metadata):
recipient = message_envelope.recipient
if recipient.type not in self._known_agent_names:
raise LookupError(f"Agent type '{recipient.type}' does not exist.")
try:
sender_id = str(message_envelope.sender) if message_envelope.sender is not None else "Unknown"
logger.info(
f"Calling message handler for {recipient} with message type "
f"{type(message_envelope.message).__name__} sent by {sender_id}"
)
event_logger.info(
MessageEvent(
payload=self._try_serialize(message_envelope.message),
sender=message_envelope.sender,
receiver=recipient,
kind=MessageKind.DIRECT,
delivery_stage=DeliveryStage.DELIVER,
)
)
recipient_agent = await self._get_agent(recipient)
message_context = MessageContext(
sender=message_envelope.sender,
topic_id=None,
is_rpc=True,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
with (
self._tracer_helper.trace_block("process", recipient_agent.id, parent=message_envelope.metadata),
MessageHandlerContext.populate_context(recipient_agent.id),
):
response = await recipient_agent.on_message(
message_envelope.message,
ctx=message_context,
)
except CancelledError as e:
if not message_envelope.future.cancelled():
message_envelope.future.set_exception(e)
self._message_queue.task_done()
event_logger.info(
MessageHandlerExceptionEvent(
payload=self._try_serialize(message_envelope.message),
handling_agent=recipient,
exception=e,
)
)
return
except BaseException as e:
message_envelope.future.set_exception(e)
self._message_queue.task_done()
event_logger.info(
MessageHandlerExceptionEvent(
payload=self._try_serialize(message_envelope.message),
handling_agent=recipient,
exception=e,
)
)
return
event_logger.info(
MessageEvent(
payload=self._try_serialize(response),
sender=message_envelope.recipient,
receiver=message_envelope.sender,
kind=MessageKind.RESPOND,
delivery_stage=DeliveryStage.SEND,
)
)
await self._message_queue.put(
ResponseMessageEnvelope(
message=response,
future=message_envelope.future,
sender=message_envelope.recipient,
recipient=message_envelope.sender,
metadata=get_telemetry_envelope_metadata(),
)
)
self._message_queue.task_done()
async def _process_publish(self, message_envelope: PublishMessageEnvelope) -> None:
with self._tracer_helper.trace_block("publish", message_envelope.topic_id, parent=message_envelope.metadata):
try:
responses: list[Awaitable[Any]] = []
recipients = await self._subscription_manager.get_subscribed_recipients(message_envelope.topic_id)
for agent_id in recipients:
# Avoid sending the message back to the sender
if message_envelope.sender is not None and agent_id == message_envelope.sender:
continue
sender_agent = (
await self._get_agent(message_envelope.sender) if message_envelope.sender is not None else None
)
sender_name = str(sender_agent.id) if sender_agent is not None else "Unknown"
logger.info(
f"Calling message handler for {agent_id.type} with message type "
f"{type(message_envelope.message).__name__} published by {sender_name}"
)
event_logger.info(
MessageEvent(
payload=self._try_serialize(message_envelope.message),
sender=message_envelope.sender,
receiver=None,
kind=MessageKind.PUBLISH,
delivery_stage=DeliveryStage.DELIVER,
)
)
message_context = MessageContext(
sender=message_envelope.sender,
topic_id=message_envelope.topic_id,
is_rpc=False,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
agent = await self._get_agent(agent_id)
async def _on_message(agent: Agent, message_context: MessageContext) -> Any:
with (
self._tracer_helper.trace_block("process", agent.id, parent=message_envelope.metadata),
MessageHandlerContext.populate_context(agent.id),
):
try:
return await agent.on_message(
message_envelope.message,
ctx=message_context,
)
except BaseException as e:
logger.error(f"Error processing publish message for {agent.id}", exc_info=True)
event_logger.info(
MessageHandlerExceptionEvent(
payload=self._try_serialize(message_envelope.message),
handling_agent=agent.id,
exception=e,
)
)
raise e
future = _on_message(agent, message_context)
responses.append(future)
await asyncio.gather(*responses)
except BaseException as e:
if not self._ignore_unhandled_handler_exceptions:
self._background_exception = e
finally:
self._message_queue.task_done()
# TODO(evmattso): if responses are given for a publish
async def _process_response(self, message_envelope: ResponseMessageEnvelope) -> None:
with self._tracer_helper.trace_block("ack", message_envelope.recipient, parent=message_envelope.metadata):
content = (
message_envelope.message.__dict__
if hasattr(message_envelope.message, "__dict__")
else message_envelope.message
)
logger.info(
f"Resolving response with message type {type(message_envelope.message).__name__} for recipient "
f"{message_envelope.recipient} from {message_envelope.sender.type}: {content}"
)
event_logger.info(
MessageEvent(
payload=self._try_serialize(message_envelope.message),
sender=message_envelope.sender,
receiver=message_envelope.recipient,
kind=MessageKind.RESPOND,
delivery_stage=DeliveryStage.DELIVER,
)
)
if not message_envelope.future.cancelled():
message_envelope.future.set_result(message_envelope.message)
self._message_queue.task_done()
async def process_next(self) -> None:
"""Process the next message in the queue.
If there is an unhandled exception in the background task, it will be raised here. `process_next` cannot be
called again after an unhandled exception is raised.
"""
await self._process_next()
async def _process_next(self) -> None:
"""Process the next message in the queue."""
if self._background_exception is not None:
e = self._background_exception
self._background_exception = None
self._message_queue.shutdown(immediate=True) # type: ignore
raise e
try:
message_envelope = await self._message_queue.get()
except QueueShutDown:
if self._background_exception is not None:
e = self._background_exception
self._background_exception = None
raise e from None
return
match message_envelope:
case SendMessageEnvelope(message=message, sender=sender, recipient=recipient, future=future):
if self._intervention_handlers is not None:
for handler in self._intervention_handlers:
with self._tracer_helper.trace_block(
"intercept", handler.__class__.__name__, parent=message_envelope.metadata
):
try:
message_context = MessageContext(
sender=sender,
topic_id=None,
is_rpc=True,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
temp_message = await handler.on_send(
message, message_context=message_context, recipient=recipient
)
_warn_if_none(temp_message, "on_send")
except BaseException as e:
future.set_exception(e)
return
if temp_message is DropMessage or isinstance(temp_message, DropMessage):
event_logger.info(
MessageDroppedEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=recipient,
kind=MessageKind.DIRECT,
)
)
future.set_exception(MessageDroppedException())
return
message_envelope.message = temp_message
task = asyncio.create_task(self._process_send(message_envelope))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
case PublishMessageEnvelope(
message=message,
sender=sender,
topic_id=topic_id,
):
if self._intervention_handlers is not None:
for handler in self._intervention_handlers:
with self._tracer_helper.trace_block(
"intercept", handler.__class__.__name__, parent=message_envelope.metadata
):
try:
message_context = MessageContext(
sender=sender,
topic_id=topic_id,
is_rpc=False,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
temp_message = await handler.on_publish(message, message_context=message_context)
_warn_if_none(temp_message, "on_publish")
except BaseException as e:
# TODO(evmattso): we should raise the intervention exception to the publisher.
logger.error(f"Exception raised in in intervention handler: {e}", exc_info=True)
return
if temp_message is DropMessage or isinstance(temp_message, DropMessage):
event_logger.info(
MessageDroppedEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=topic_id,
kind=MessageKind.PUBLISH,
)
)
return
message_envelope.message = temp_message
task = asyncio.create_task(self._process_publish(message_envelope))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
case ResponseMessageEnvelope(message=message, sender=sender, recipient=recipient, future=future):
if self._intervention_handlers is not None:
for handler in self._intervention_handlers:
try:
temp_message = await handler.on_response(message, sender=sender, recipient=recipient)
_warn_if_none(temp_message, "on_response")
except BaseException as e:
# TODO(evmattso): should we raise the exception to sender of the response instead?
future.set_exception(e)
return
if temp_message is DropMessage or isinstance(temp_message, DropMessage):
event_logger.info(
MessageDroppedEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=recipient,
kind=MessageKind.RESPOND,
)
)
future.set_exception(MessageDroppedException())
return
message_envelope.message = temp_message
task = asyncio.create_task(self._process_response(message_envelope))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Yield control to the message loop to allow other tasks to run
await asyncio.sleep(0)
def start(self) -> None:
"""Start the runtime message processing loop. This runs in a background task."""
if self._run_context is not None:
raise RuntimeError("Runtime is already started")
self._run_context = RunContext(self)
async def close(self) -> None:
"""Calls :meth:`stop` if applicable and the :meth:`Agent.close` method on all instantiated agents."""
# stop the runtime if it hasn't been stopped yet
if self._run_context is not None:
await self.stop()
# close all the agents that have been instantiated
for agent_id in self._instantiated_agents:
agent = await self._get_agent(agent_id)
await agent.close()
async def stop(self) -> None:
"""Immediately stop the runtime message processing loop.
The currently processing message will be completed, but all others following it will be discarded.
"""
if self._run_context is None:
raise RuntimeError("Runtime is not started")
try:
await self._run_context.stop()
finally:
self._run_context = None
self._message_queue = Queue()
async def stop_when_idle(self) -> None:
"""Stop the runtime message processing loop when there is no outstanding message being processed or queued.
This is the most common way to stop the runtime.
"""
if self._run_context is None:
raise RuntimeError("Runtime is not started")
try:
await self._run_context.stop_when_idle()
finally:
self._run_context = None
self._message_queue = Queue()
async def stop_when(self, condition: Callable[[], bool]) -> None:
"""Stop the runtime message processing loop when the condition is met.
.. caution::
This method is not recommended to be used, and is here for legacy
reasons. It will spawn a busy loop to continually check the
condition. It is much more efficient to call `stop_when_idle` or
`stop` instead. If you need to stop the runtime based on a
condition, consider using a background task and asyncio.Event to
signal when the condition is met and the background task should call
stop.
"""
if self._run_context is None:
raise RuntimeError("Runtime is not started")
await self._run_context.stop_when(condition)
self._run_context = None
self._message_queue = Queue()
async def agent_metadata(self, agent: AgentId) -> AgentMetadata:
"""Get the metadata for an agent."""
return (await self._get_agent(agent)).metadata
async def agent_save_state(self, agent: AgentId) -> Mapping[str, Any]:
"""Save the state of a single agent."""
return await (await self._get_agent(agent)).save_state()
async def agent_load_state(self, agent: AgentId, state: Mapping[str, Any]) -> None:
"""Load the state of a single agent."""
await (await self._get_agent(agent)).load_state(state)
async def register_factory(
self,
type: str | AgentType,
agent_factory: Callable[[], T | Awaitable[T]],
*,
expected_class: type[T] | None = None,
) -> AgentType:
"""Register a factory for creating agents."""
if isinstance(type, str):
type = CoreAgentType(type)
if type.type in self._agent_factories:
raise ValueError(f"Agent with type {type} already exists.")
async def factory_wrapper() -> T:
maybe_agent_instance = agent_factory()
if inspect.isawaitable(maybe_agent_instance):
agent_instance = await maybe_agent_instance
else:
agent_instance = maybe_agent_instance
if expected_class is not None and type_func_alias(agent_instance) != expected_class:
raise ValueError("Factory registered using the wrong type.")
return agent_instance
self._agent_factories[type.type] = factory_wrapper
return type
async def _invoke_agent_factory(
self,
agent_factory: Callable[[], T | Awaitable[T]] | Callable[[CoreRuntime, AgentId], T | Awaitable[T]],
agent_id: AgentId,
) -> T:
with AgentInstantiationContext.populate_context((self, agent_id)):
try:
if len(inspect.signature(agent_factory).parameters) == 0:
factory_one = cast(Callable[[], T], agent_factory)
agent = factory_one()
elif len(inspect.signature(agent_factory).parameters) == 2:
warnings.warn(
"Agent factories that take two arguments are deprecated. Use AgentInstantiationContext "
"instead. Two arg factories will be removed in a future version.",
stacklevel=2,
)
factory_two = cast(Callable[[CoreRuntime, AgentId], T], agent_factory)
agent = factory_two(self, agent_id)
else:
raise ValueError("Agent factory must take 0 or 2 arguments.")
if inspect.isawaitable(agent):
return cast(T, await agent)
return agent
except BaseException as e:
event_logger.info(
AgentConstructionExceptionEvent(
agent_id=agent_id,
exception=e,
)
)
logger.error(f"Error constructing agent {agent_id}", exc_info=True)
raise
async def _get_agent(self, agent_id: AgentId) -> Agent:
if agent_id in self._instantiated_agents:
return self._instantiated_agents[agent_id]
if agent_id.type not in self._agent_factories:
raise LookupError(f"Agent with name {agent_id.type} not found.")
agent_factory = self._agent_factories[agent_id.type]
agent = await self._invoke_agent_factory(agent_factory, agent_id)
self._instantiated_agents[agent_id] = agent
return agent
# 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."""
if id.type not in self._agent_factories:
raise LookupError(f"Agent with name {id.type} not found.")
# TODO(evmattso): check if remote
agent_instance = await self._get_agent(id)
if not isinstance(agent_instance, type):
raise TypeError(
f"Agent with name {id.type} is not of type {type.__name__}. "
f"It is of type {type_func_alias(agent_instance).__name__}"
)
return agent_instance
async def add_subscription(self, subscription: Subscription) -> None:
"""Add a subscription to the runtime."""
await self._subscription_manager.add_subscription(subscription)
async def remove_subscription(self, id: str) -> None:
"""Remove a subscription from the runtime."""
await self._subscription_manager.remove_subscription(id)
async def get(
self, id_or_type: AgentId | AgentType | str, /, key: str = "default", *, lazy: bool = True
) -> AgentId:
"""Get an agent by id or type."""
return await get_impl(
id_or_type=id_or_type,
key=key,
lazy=lazy,
instance_getter=self._get_agent,
)
def add_message_serializer(self, serializer: MessageSerializer[Any] | Sequence[MessageSerializer[Any]]) -> None:
"""Add a message serializer to the runtime."""
self._serialization_registry.add_serializer(serializer)
def _try_serialize(self, message: Any) -> str:
try:
type_name = self._serialization_registry.type_name(message)
return self._serialization_registry.serialize(
message, type_name=type_name, data_content_type=JSON_DATA_CONTENT_TYPE
).decode("utf-8")
except ValueError:
return "Message could not be serialized"
@@ -0,0 +1,41 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, ClassVar
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class MessageHandlerContext:
"""Context for message handlers."""
def __init__(self) -> None:
"""Instantiate the MessageHandlerContext class."""
raise RuntimeError(
"MessageHandlerContext cannot be instantiated. It is a static class that provides context management for "
"message handling."
)
_MESSAGE_HANDLER_CONTEXT: ClassVar[ContextVar[AgentId]] = ContextVar("_MESSAGE_HANDLER_CONTEXT")
@classmethod
@contextmanager
def populate_context(cls, ctx: AgentId) -> Generator[None, Any, None]:
"""Populate the context with the current agent ID."""
token = MessageHandlerContext._MESSAGE_HANDLER_CONTEXT.set(ctx)
try:
yield
finally:
MessageHandlerContext._MESSAGE_HANDLER_CONTEXT.reset(token)
@classmethod
def agent_id(cls) -> AgentId:
"""Get the current agent ID."""
try:
return cls._MESSAGE_HANDLER_CONTEXT.get()
except LookupError as e:
raise RuntimeError("MessageHandlerContext.agent_id() must be called within a message handler.") from e
@@ -0,0 +1,274 @@
# Copyright (c) Microsoft. All rights reserved.
# Copy of Asyncio queue: https://github.com/python/cpython/blob/main/Lib/asyncio/queues.py
# So that shutdown can be used in <3.13
# Modified to work outside of the asyncio package
import asyncio
import collections
import threading
from typing import Generic, TypeVar
from semantic_kernel.utils.feature_stage_decorator import experimental
_global_lock = threading.Lock()
class _LoopBoundMixin:
_loop = None
def _get_loop(self) -> asyncio.AbstractEventLoop:
loop = asyncio.get_running_loop()
if self._loop is None:
with _global_lock:
if self._loop is None:
self._loop = loop
if loop is not self._loop:
raise RuntimeError(f"{self!r} is bound to a different event loop")
return loop
@experimental
class QueueShutDown(Exception):
"""Raised when putting on to or getting from a shut-down Queue."""
pass
T = TypeVar("T")
@experimental
class Queue(_LoopBoundMixin, Generic[T]):
"""A queue class that supports async operations."""
def __init__(self, maxsize: int = 0):
"""Initialize the queue."""
self._maxsize = maxsize
self._getters = collections.deque[asyncio.Future[None]]()
self._putters = collections.deque[asyncio.Future[None]]()
self._unfinished_tasks = 0
self._finished = asyncio.Event()
self._finished.set()
self._queue = collections.deque[T]()
self._is_shutdown = False
# These three are overridable in subclasses.
def _get(self) -> T:
return self._queue.popleft()
def _put(self, item: T) -> None:
self._queue.append(item)
# End of the overridable methods.
def _wakeup_next(self, waiters: collections.deque[asyncio.Future[None]]) -> None:
# Wake up the next waiter (if any) that isn't cancelled.
while waiters:
waiter = waiters.popleft()
if not waiter.done():
waiter.set_result(None)
break
def __repr__(self) -> str:
"""Generate a string representation of the Queue."""
return f"<{type(self).__name__} at {id(self):#x} {self._format()}>"
def __str__(self) -> str:
"""Convert the Queue to a string."""
return f"<{type(self).__name__} {self._format()}>"
def _format(self) -> str:
result = f"maxsize={self._maxsize!r}"
if getattr(self, "_queue", None):
result += f" _queue={list(self._queue)!r}"
if self._getters:
result += f" _getters[{len(self._getters)}]"
if self._putters:
result += f" _putters[{len(self._putters)}]"
if self._unfinished_tasks:
result += f" tasks={self._unfinished_tasks}"
if self._is_shutdown:
result += " shutdown"
return result
def qsize(self) -> int:
"""Number of items in the queue."""
return len(self._queue)
@property
def maxsize(self) -> int:
"""Number of items allowed in the queue."""
return self._maxsize
def empty(self) -> bool:
"""Return True if the queue is empty, False otherwise."""
return not self._queue
def full(self) -> bool:
"""Return True if there are maxsize items in the queue.
Note: if the Queue was initialized with maxsize=0 (the default),
then full() is never True.
"""
if self._maxsize <= 0:
return False
return self.qsize() >= self._maxsize
async def put(self, item: T) -> None:
"""Put an item into the queue.
Put an item into the queue. If the queue is full, wait until a free
slot is available before adding item.
Raises QueueShutDown if the queue has been shut down.
"""
while self.full():
if self._is_shutdown:
raise QueueShutDown
putter = self._get_loop().create_future()
self._putters.append(putter)
try:
await putter
except:
putter.cancel() # Just in case putter is not done yet.
try: # noqa: SIM105
# Clean self._putters from canceled putters.
self._putters.remove(putter)
except ValueError:
# The putter could be removed from self._putters by a
# previous get_nowait call or a shutdown call.
pass
if not self.full() and not putter.cancelled():
# We were woken up by get_nowait(), but can't take
# the call. Wake up the next in line.
self._wakeup_next(self._putters)
raise
return self.put_nowait(item)
def put_nowait(self, item: T) -> None:
"""Put an item into the queue without blocking.
If no free slot is immediately available, raise QueueFull.
Raises QueueShutDown if the queue has been shut down.
"""
if self._is_shutdown:
raise QueueShutDown
if self.full():
raise asyncio.QueueFull
self._put(item)
self._unfinished_tasks += 1
self._finished.clear()
self._wakeup_next(self._getters)
async def get(self) -> T:
"""Remove and return an item from the queue.
If queue is empty, wait until an item is available.
Raises QueueShutDown if the queue has been shut down and is empty, or
if the queue has been shut down immediately.
"""
while self.empty():
if self._is_shutdown and self.empty():
raise QueueShutDown
getter = self._get_loop().create_future()
self._getters.append(getter)
try:
await getter
except:
getter.cancel() # Just in case getter is not done yet.
try: # noqa: SIM105
# Clean self._getters from canceled getters.
self._getters.remove(getter)
except ValueError:
# The getter could be removed from self._getters by a
# previous put_nowait call, or a shutdown call.
pass
if not self.empty() and not getter.cancelled():
# We were woken up by put_nowait(), but can't take
# the call. Wake up the next in line.
self._wakeup_next(self._getters)
raise
return self.get_nowait()
def get_nowait(self) -> T:
"""Remove and return an item from the queue.
Return an item if one is immediately available, else raise QueueEmpty.
Raises QueueShutDown if the queue has been shut down and is empty, or
if the queue has been shut down immediately.
"""
if self.empty():
if self._is_shutdown:
raise QueueShutDown
raise asyncio.QueueEmpty
item = self._get()
self._wakeup_next(self._putters)
return item
def task_done(self) -> None:
"""Indicate that a formerly enqueued task is complete.
Used by queue consumers. For each get() used to fetch a task,
a subsequent call to task_done() tells the queue that the processing
on the task is complete.
If a join() is currently blocking, it will resume when all items have
been processed (meaning that a task_done() call was received for every
item that had been put() into the queue).
shutdown(immediate=True) calls task_done() for each remaining item in
the queue.
Raises ValueError if called more times than there were items placed in
the queue.
"""
if self._unfinished_tasks <= 0:
raise ValueError("task_done() called too many times")
self._unfinished_tasks -= 1
if self._unfinished_tasks == 0:
self._finished.set()
async def join(self) -> None:
"""Block until all items in the queue have been gotten and processed.
The count of unfinished tasks goes up whenever an item is added to the
queue. The count goes down whenever a consumer calls task_done() to
indicate that the item was retrieved and all work on it is complete.
When the count of unfinished tasks drops to zero, join() unblocks.
"""
if self._unfinished_tasks > 0:
await self._finished.wait()
def shutdown(self, immediate: bool = False) -> None:
"""Shut-down the queue, making queue gets and puts raise QueueShutDown.
By default, gets will only raise once the queue is empty. Set
'immediate' to True to make gets raise immediately instead.
All blocked callers of put() and get() will be unblocked. If
'immediate', a task is marked as done for each item remaining in
the queue, which may unblock callers of join().
"""
self._is_shutdown = True
if immediate:
while not self.empty():
self._get()
if self._unfinished_tasks > 0:
self._unfinished_tasks -= 1
if self._unfinished_tasks == 0:
self._finished.set()
# All getters need to re-check queue-empty to raise ShutDown
while self._getters:
getter = self._getters.popleft()
if not getter.done():
getter.set_result(None)
while self._putters:
putter = self._putters.popleft()
if not putter.done():
putter.set_result(None)
@@ -0,0 +1,94 @@
# Copyright (c) Microsoft. All rights reserved.
from collections import defaultdict
from collections.abc import Awaitable, Callable, Sequence
from typing import DefaultDict
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_type import AgentType
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
@experimental
async def get_impl(
*,
id_or_type: AgentId | AgentType | str,
key: str,
lazy: bool,
instance_getter: Callable[[AgentId], Awaitable[Agent]],
) -> AgentId:
"""Get the implementation of an agent."""
if isinstance(id_or_type, AgentId):
if not lazy:
await instance_getter(id_or_type)
return id_or_type
type_str = id_or_type if isinstance(id_or_type, str) else id_or_type.type
id = CoreAgentId(type_str, key)
if not lazy:
await instance_getter(id)
return id
@experimental
class SubscriptionManager:
"""Manages subscriptions for agents."""
def __init__(self) -> None:
"""Initialize the SubscriptionManager."""
self._subscriptions: list[Subscription] = []
self._seen_topics: set[TopicId] = set()
self._subscribed_recipients: DefaultDict[TopicId, list[AgentId]] = defaultdict(list)
@property
def subscriptions(self) -> Sequence[Subscription]:
"""Get the list of subscriptions."""
return self._subscriptions
async def add_subscription(self, subscription: Subscription) -> None:
"""Add a subscription to the manager."""
# Check if the subscription already exists
if any(sub == subscription for sub in self._subscriptions):
raise ValueError("Subscription already exists")
self._subscriptions.append(subscription)
self._rebuild_subscriptions(self._seen_topics)
async def remove_subscription(self, id: str) -> None:
"""Remove a subscription from the manager."""
# Check if the subscription exists
if not any(sub.id == id for sub in self._subscriptions):
raise ValueError("Subscription does not exist")
def is_not_sub(x: Subscription) -> bool:
return x.id != id
self._subscriptions = list(filter(is_not_sub, self._subscriptions))
# Rebuild the subscriptions
self._rebuild_subscriptions(self._seen_topics)
async def get_subscribed_recipients(self, topic: TopicId) -> list[AgentId]:
"""Get the list of recipients subscribed to a topic."""
if topic not in self._seen_topics:
self._build_for_new_topic(topic)
return self._subscribed_recipients[topic]
# TODO(evmattso): optimize this...
def _rebuild_subscriptions(self, topics: set[TopicId]) -> None:
"""Rebuild the subscriptions for the given topics."""
self._subscribed_recipients.clear()
for topic in topics:
self._build_for_new_topic(topic)
def _build_for_new_topic(self, topic: TopicId) -> None:
"""Build the subscriptions for a new topic."""
self._seen_topics.add(topic)
for subscription in self._subscriptions:
if subscription.is_match(topic):
self._subscribed_recipients[topic].append(subscription.map_to_agent(topic))
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, ClassVar
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class SubscriptionInstantiationContext:
"""Context manager for subscription instantiation."""
def __init__(self) -> None:
"""Prevent instantiation of SubscriptionInstantiationContext."""
raise RuntimeError(
"SubscriptionInstantiationContext cannot be instantiated. It is a static class that provides context "
"management for subscription instantiation."
)
_SUBSCRIPTION_CONTEXT_VAR: ClassVar[ContextVar[AgentType]] = ContextVar("_SUBSCRIPTION_CONTEXT_VAR")
@classmethod
@contextmanager
def populate_context(cls, ctx: AgentType) -> Generator[None, Any, None]:
"""Populate the context with the agent type."""
token = SubscriptionInstantiationContext._SUBSCRIPTION_CONTEXT_VAR.set(ctx)
try:
yield
finally:
SubscriptionInstantiationContext._SUBSCRIPTION_CONTEXT_VAR.reset(token)
@classmethod
def agent_type(cls) -> AgentType:
"""Get the agent type from the context."""
try:
return cls._SUBSCRIPTION_CONTEXT_VAR.get()
except LookupError as e:
raise RuntimeError(
"SubscriptionInstantiationContext.runtime() must be called within an instantiation context such as "
"when the AgentRuntime is instantiating an agent. Mostly likely this was caused by directly "
"instantiating an agent instead of using the AgentRuntime to do so."
) from e
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
import uuid
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
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
@experimental
class TypePrefixSubscription(Subscription):
"""This subscription matches on topics based on a prefix of the type and maps to agents.
It uses the source of the topic as the agent key. This subscription causes each source to have
its own agent instance.
Args:
topic_type_prefix (str): Topic type prefix to match against
agent_type (str): Agent type to handle this subscription
"""
def __init__(self, topic_type_prefix: str, agent_type: str | AgentType, id: str | None = None):
"""Initialize the TypePrefixSubscription."""
self._topic_type_prefix = topic_type_prefix
if isinstance(agent_type, AgentType):
self._agent_type = agent_type.type
else:
self._agent_type = agent_type
self._id = id or str(uuid.uuid4())
@property
def id(self) -> str:
"""Get the id of the subscription."""
return self._id
@property
def topic_type_prefix(self) -> str:
"""Get the topic type prefix of the subscription."""
return self._topic_type_prefix
@property
def agent_type(self) -> str:
"""Get the agent type of the subscription."""
return self._agent_type
def is_match(self, topic_id: TopicId) -> bool:
"""Check if the topic_id matches the subscription."""
return topic_id.type.startswith(self._topic_type_prefix)
def map_to_agent(self, topic_id: TopicId) -> AgentId:
"""Map the topic_id to an agent_id."""
if not self.is_match(topic_id):
raise CantHandleException("TopicId does not match the subscription")
return CoreAgentId(type=self._agent_type, key=topic_id.source)
def __eq__(self, other: object) -> bool:
"""Check if two subscriptions are equal."""
if not isinstance(other, TypePrefixSubscription):
return False
return self.id == other.id or (
self.agent_type == other.agent_type and self.topic_type_prefix == other.topic_type_prefix
)
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
import uuid
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
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
@experimental
class TypeSubscription(Subscription):
"""This subscription matches on topics based on the type and maps to agents.
It uses the source of the topic as the agent key. This subscription causes each source to have
its own agent instance.
"""
def __init__(self, topic_type: str, agent_type: str | AgentType, id: str | None = None):
"""Initialize the TypeSubscription.
Args:
topic_type (str): Topic type to match against
agent_type (str): Agent type to handle this subscription
id (str | None): Id of the subscription. If None, a new id will be generated.
"""
self._topic_type = topic_type
if isinstance(agent_type, AgentType):
self._agent_type = agent_type.type
else:
self._agent_type = agent_type
self._id = id or str(uuid.uuid4())
@property
def id(self) -> str:
"""Get the id of the subscription."""
return self._id
@property
def topic_type(self) -> str:
"""Get the topic type of the subscription."""
return self._topic_type
@property
def agent_type(self) -> str:
"""Get the agent type of the subscription."""
return self._agent_type
def is_match(self, topic_id: TopicId) -> bool:
"""Check if the topic_id matches the subscription."""
return topic_id.type == self._topic_type
def map_to_agent(self, topic_id: TopicId) -> AgentId:
"""Map the topic_id to an agent_id."""
if not self.is_match(topic_id):
raise CantHandleException("TopicId does not match the subscription")
return CoreAgentId(type=self._agent_type, key=topic_id.source)
def __eq__(self, other: object) -> bool:
"""Check if two subscriptions are equal."""
if not isinstance(other, TypeSubscription):
return False
return self.id == other.id or (self.agent_type == other.agent_type and self.topic_type == other.topic_type)