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