chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user