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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1 @@
# 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)