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,30 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ActorStateKeys(Enum):
"""Keys used to store actor state in Dapr."""
# StepActor Keys
StepParentProcessId = "parentProcessId"
StepInfoState = "DaprStepInfo"
StepStateJson = "kernelStepStateJson"
StepStateType = "kernelStepStateType"
StepIncomingMessagesState = "incomingMessagesState"
# ProcessActor Keys
ProcessInfoState = "DaprProcessInfo"
StepActivatedState = "kernelStepActivated"
# MessageBufferActor Keys
MessageQueueState = "DaprMessageBufferState"
# ExternalEventBufferActor Keys
ExternalEventQueueState = "DaprExternalEventBufferState"
# EventBufferActor Keys
EventQueueState = "DaprEventBufferState"
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
from queue import Queue
from dapr.actor import Actor
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
from semantic_kernel.processes.dapr_runtime.interfaces.message_buffer_interface import MessageBufferInterface
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
@experimental
class EventBufferActor(Actor, MessageBufferInterface):
"""Represents a message buffer actor that manages a queue of JSON strings representing events."""
queue: Queue = Queue()
async def enqueue(self, message: str) -> None:
"""Enqueues a JSON string message event into the buffer and updates the state.
Args:
message: The message event to enqueue as a JSON string.
Raises:
Exception: If an error occurs during the enqueue operation.
"""
try:
self.queue.put(message)
queue_list = list(self.queue.queue)
queue_json = json.dumps(queue_list)
await self._state_manager.try_add_state(ActorStateKeys.EventQueueState.value, queue_json)
await self._state_manager.save_state()
logger.info(f"Enqueued message and updated state for actor ID: {self.id.id}")
except Exception as e:
error_message = str(e)
logger.error(f"Error in enqueue: {error_message}")
raise Exception(error_message)
async def dequeue_all(self) -> list[str]:
"""Dequeues all process events from the buffer and returns them as JSON strings.
Returns:
A list of JSON strings representing the dequeued messages.
Raises:
Exception: If an error occurs during the dequeue operation.
"""
try:
items = []
while not self.queue.empty():
items.append(self.queue.get())
await self._state_manager.try_add_state(ActorStateKeys.EventQueueState.value, json.dumps([]))
await self._state_manager.save_state()
logger.info(f"Dequeued all messages and updated state for actor ID: {self.id.id}")
return items
except Exception as e:
error_message = str(e)
logger.error(f"Error in dequeue_all: {error_message}")
raise Exception(error_message)
async def _on_activate(self) -> None:
"""Activates the actor and initializes the queue state from Dapr storage.
Raises:
Exception: If an error occurs during actor activation.
"""
try:
logger.info(f"Activating actor with ID: {self.id.id}")
state_exists, queue_json = await self._state_manager.try_get_state(ActorStateKeys.EventQueueState.value)
if state_exists and queue_json:
queue_list = json.loads(queue_json)
self.queue = Queue()
for item in queue_list:
self.queue.put(item)
logger.info(f"Reconstructed queue from state for actor ID: {self.id.id}")
else:
self.queue = Queue()
logger.info(f"No existing state found. Initialized empty queue for actor ID: {self.id.id}")
except Exception as e:
error_message = str(e)
logger.error(f"Error in _on_activate: {error_message}")
raise Exception(error_message)
@@ -0,0 +1,106 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
from queue import Queue
from dapr.actor import Actor, ActorId
from dapr.actor.runtime.context import ActorRuntimeContext
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
from semantic_kernel.processes.dapr_runtime.interfaces.external_event_buffer_interface import (
ExternalEventBufferInterface,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
@experimental
class ExternalEventBufferActor(Actor, ExternalEventBufferInterface):
"""Represents a message buffer actor that follows the MessageBuffer abstract class."""
def __init__(self, ctx: ActorRuntimeContext, actor_id: ActorId):
"""Initializes a new instance of ExternalEventBufferActor.
Args:
ctx: The actor runtime context.
actor_id: The unique ID for the actor.
"""
super().__init__(ctx, actor_id)
self.queue: Queue[str] = Queue()
async def enqueue(self, message: str) -> None:
"""Enqueues a message event into the buffer.
Args:
message: The message event to enqueue as a JSON string.
Raises:
Exception: If an error occurs during enqueue operation.
"""
try:
self.queue.put(message)
queue_list = list(self.queue.queue)
queue_state = json.dumps(queue_list)
await self._state_manager.try_add_state(ActorStateKeys.ExternalEventQueueState.value, queue_state)
await self._state_manager.save_state()
logger.info(f"Enqueued message and updated state for actor ID: {self.id.id}")
except Exception as e:
error_message = str(e)
logger.error(f"Error in enqueue: {error_message}")
raise Exception(error_message)
async def dequeue_all(self) -> list[str]:
"""Dequeues all process events from the buffer and returns them as JSON strings.
Returns:
A list of JSON strings representing the dequeued messages.
Raises:
Exception: If an error occurs during dequeue operation.
"""
try:
items = []
while not self.queue.empty():
items.append(self.queue.get())
await self._state_manager.try_add_state(ActorStateKeys.ExternalEventQueueState.value, json.dumps([]))
await self._state_manager.save_state()
logger.info(f"Dequeued all messages and updated state for actor ID: {self.id.id}")
return items
except Exception as e:
error_message = str(e)
logger.error(f"Error in dequeue_all: {error_message}")
raise Exception(error_message)
async def _on_activate(self) -> None:
"""Called when the actor is activated to initialize state.
Raises:
Exception: If an error occurs during actor activation.
"""
try:
logger.info(f"Activating actor with ID: {self.id.id}")
state_exists, queue_json = await self._state_manager.try_get_state(
ActorStateKeys.ExternalEventQueueState.value
)
if state_exists and queue_json:
queue_list = json.loads(queue_json)
self.queue = Queue()
for item in queue_list:
self.queue.put(item)
logger.info(f"Reconstructed queue from state for actor ID: {self.id.id}")
else:
self.queue = Queue()
logger.info(f"No existing state found. Initialized empty queue for actor ID: {self.id.id}")
except Exception as e:
error_message = str(e)
logger.error(f"Error in _on_activate: {error_message}")
raise Exception(error_message)
@@ -0,0 +1,77 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
from queue import Queue
from dapr.actor import Actor, ActorId
from dapr.actor.runtime.context import ActorRuntimeContext
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
from semantic_kernel.processes.dapr_runtime.interfaces.message_buffer_interface import MessageBufferInterface
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class MessageBufferActor(Actor, MessageBufferInterface):
"""Represents a message buffer actor that follows the MessageBuffer abstract class."""
def __init__(self, ctx: ActorRuntimeContext, actor_id: ActorId):
"""Initializes a new instance of MessageBufferActor."""
super().__init__(ctx, actor_id)
self.queue: Queue[str] = Queue()
async def enqueue(self, message: str) -> None:
"""Enqueues a message event into the buffer and updates the state.
Args:
message (str): The message to enqueue.
"""
try:
self.queue.put(message)
queue_list = list(self.queue.queue)
await self._state_manager.try_add_state(ActorStateKeys.MessageQueueState.value, queue_list)
await self._state_manager.save_state()
except Exception as e:
error_message = str(e)
logger.error(f"Error in MessageBufferActor enqueue: {error_message}")
raise Exception(error_message)
async def dequeue_all(self) -> list[str]:
"""Dequeues all process events from the buffer and returns them as a list of strings."""
try:
items = []
while not self.queue.empty():
items.append(self.queue.get())
await self._state_manager.try_add_state(ActorStateKeys.MessageQueueState.value, json.dumps([]))
await self._state_manager.save_state()
return items
except Exception as e:
error_message = str(e)
logger.error(f"Error in MessageBufferActor dequeue_all: {error_message}")
raise Exception(error_message)
async def _on_activate(self) -> None:
"""Called when the actor is activated."""
try:
logger.info(f"Activating actor with ID: {self.id.id}")
has_value, queue_list = await self._state_manager.try_get_state(ActorStateKeys.MessageQueueState.value)
if has_value and queue_list:
self.queue = Queue()
for item_dict in queue_list:
self.queue.put(item_dict)
else:
self.queue = Queue()
except Exception as e:
error_message = str(e)
logger.error(f"Error in MessageBufferActor _on_activate: {error_message}")
raise Exception(error_message)
@@ -0,0 +1,450 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import contextlib
import json
import logging
import uuid
from collections.abc import Callable, MutableSequence
from queue import Queue
from typing import Any
from dapr.actor import ActorId, ActorProxy
from dapr.actor.runtime.context import ActorRuntimeContext
from semantic_kernel.exceptions.kernel_exceptions import KernelException
from semantic_kernel.exceptions.process_exceptions import ProcessEventUndefinedException
from semantic_kernel.kernel import Kernel
from semantic_kernel.processes.const import END_PROCESS_ID
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
from semantic_kernel.processes.dapr_runtime.actors.event_buffer_actor import EventBufferActor
from semantic_kernel.processes.dapr_runtime.actors.external_event_buffer_actor import ExternalEventBufferActor
from semantic_kernel.processes.dapr_runtime.actors.message_buffer_actor import MessageBufferActor
from semantic_kernel.processes.dapr_runtime.actors.step_actor import StepActor
from semantic_kernel.processes.dapr_runtime.dapr_process_info import DaprProcessInfo
from semantic_kernel.processes.dapr_runtime.dapr_step_info import DaprStepInfo
from semantic_kernel.processes.dapr_runtime.interfaces.event_buffer_interface import EventBufferInterface
from semantic_kernel.processes.dapr_runtime.interfaces.external_event_buffer_interface import (
ExternalEventBufferInterface,
)
from semantic_kernel.processes.dapr_runtime.interfaces.message_buffer_interface import MessageBufferInterface
from semantic_kernel.processes.dapr_runtime.interfaces.process_interface import ProcessInterface
from semantic_kernel.processes.dapr_runtime.interfaces.step_interface import StepInterface
from semantic_kernel.processes.kernel_process.kernel_process_event import (
KernelProcessEvent,
KernelProcessEventVisibility,
)
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
from semantic_kernel.processes.process_event import ProcessEvent
from semantic_kernel.processes.process_message import ProcessMessage
from semantic_kernel.processes.process_message_factory import ProcessMessageFactory
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class ProcessActor(StepActor, ProcessInterface):
"""A local process that contains a collection of steps."""
max_supersteps: int = 100
def __init__(self, ctx: ActorRuntimeContext, actor_id: ActorId, kernel: Kernel, factories: dict[str, Callable]):
"""Initializes a new instance of ProcessActor.
Args:
ctx: The actor runtime context.
actor_id: The unique ID for the actor.
kernel: The Kernel dependency to be injected.
factories: The factory dictionary that contains step types to factory methods.
"""
super().__init__(ctx, actor_id, kernel, factories)
self.kernel = kernel
self.factories = factories
self.steps: MutableSequence[StepInterface] = []
self.step_infos: MutableSequence[DaprStepInfo] = []
self.initialize_task: bool | None = False
self.external_event_queue: Queue = Queue()
self.process_task: asyncio.Task | None = None
self.process: DaprProcessInfo | None = None
@property
def name(self) -> str:
"""Gets the name of the step."""
if self.process is None or self.process.state is None or self.process.state.name is None:
error_message = "The process must be initialized before accessing the name property."
logger.error(error_message)
raise KernelException(error_message)
return self.process.state.name
async def initialize_process(self, input: dict | str) -> None:
"""Initializes the process."""
if isinstance(input, str):
input = json.loads(input)
if not isinstance(input, dict):
raise TypeError("input must be a JSON string or a dictionary")
process_info_data = input.get("process_info")
parent_process_id = input.get("parent_process_id")
max_supersteps = input.get("max_supersteps", None)
if process_info_data is None:
raise ValueError("The process info is not defined.")
if max_supersteps is not None:
self.max_supersteps = max_supersteps
if isinstance(process_info_data, str):
process_info_dict = json.loads(process_info_data)
elif isinstance(process_info_data, dict):
process_info_dict = process_info_data
else:
raise TypeError("process_info must be a JSON string or a dictionary")
dapr_process_info = DaprProcessInfo.model_validate(process_info_dict)
if dapr_process_info.steps is None:
raise ValueError("The process info does not contain any steps.")
if self.initialize_task:
return
await self._initialize_process_actor(dapr_process_info, parent_process_id)
try:
# Serialize dapr_process_info before saving
process_info_serialized = dapr_process_info.model_dump()
await self._state_manager.try_add_state(ActorStateKeys.ProcessInfoState.value, process_info_serialized)
await self._state_manager.try_add_state(
ActorStateKeys.StepParentProcessId.value, parent_process_id if parent_process_id else ""
)
await self._state_manager.try_add_state(ActorStateKeys.StepActivatedState.value, True)
await self._state_manager.save_state()
logger.info(f"Initialized process for: {dapr_process_info} and parent process ID: {parent_process_id}")
except Exception as ex:
error_message = str(ex)
logger.error(f"Error in initialize_process: {error_message}")
raise Exception(error_message)
async def start(self, keep_alive: bool = True) -> None:
"""Starts the process."""
if not self.initialize_task:
raise ValueError("The process has not been initialized.")
# Only create the task if it doesn't already exist or is not running
if not self.process_task or self.process_task.done():
self.process_task = asyncio.create_task(
self.internal_execute(max_supersteps=self.max_supersteps, keep_alive=keep_alive)
)
async def run_once(self, process_event: KernelProcessEvent | str | None) -> None:
"""Starts the process with an initial event and waits for it to finish.
Args:
process_event: The initial event to start the process represented as a string of a KernelProcessEvent
"""
if process_event is None:
raise ProcessEventUndefinedException("The process event must be specified.")
external_event_queue: ExternalEventBufferActor = ActorProxy.create( # type: ignore
actor_type=f"{ExternalEventBufferActor.__name__}",
actor_id=ActorId(self.id.id),
actor_interface=ExternalEventBufferInterface,
)
try:
await external_event_queue.enqueue(
process_event.model_dump_json() if isinstance(process_event, KernelProcessEvent) else process_event
)
logger.info(f"Run once for process event: {process_event}")
await self.start(keep_alive=False)
if self.process_task:
try:
await self.process_task
except asyncio.CancelledError:
logger.error("Process task was cancelled")
except Exception as ex:
logger.error(f"Error in run_once: {ex}")
raise ex
async def stop(self):
"""Stops a running process."""
if not self.process_task or self.process_task.done():
return # Task is already finished or hasn't started
self.process_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self.process_task
async def initialize_step(self, input: str) -> None:
"""Initializes the step."""
# The process does not need any further initialization
pass
async def activate_step(self):
"""Overrides the step's activate_step method."""
# The process does not need any further initialization
pass
async def _on_activate(self) -> None:
"""Called when the actor is activated."""
try:
has_value, existing_process_info = await self._state_manager.try_get_state(
ActorStateKeys.ProcessInfoState.value
)
if has_value and existing_process_info:
has_value, parent_process_id = await self._state_manager.try_get_state(
ActorStateKeys.StepParentProcessId.value
)
combined_input = {
"process_info": existing_process_info,
"parent_process_id": parent_process_id,
}
await self.initialize_process(combined_input)
except Exception as e:
error_message = str(e)
logger.error(f"Error in _on_activate: {error_message}")
raise Exception(error_message)
async def send_message(self, process_event: KernelProcessEvent):
"""Sends a message to the process."""
if process_event is None:
raise ProcessEventUndefinedException("The process event must be specified.")
self.external_event_queue.put(process_event)
async def get_process_info(self) -> dict:
"""Gets the process information."""
return await self.to_dapr_process_info()
async def to_dapr_process_info(self) -> dict:
"""Converts the process to a Dapr process info."""
if self.process is None:
raise ValueError("The process must be initialized before converting to DaprProcessInfo.")
if self.process.inner_step_python_type is None:
raise ValueError("The inner step type must be defined before converting to DaprProcessInfo.")
process_state = KernelProcessState(name=self.name, version=self.process.state.version, id=self.id.id)
step_tasks = [step.to_dapr_step_info() for step in self.steps]
steps_as_dicts = await asyncio.gather(*step_tasks)
dapr_process_info = DaprProcessInfo(
inner_step_python_type=self.process.inner_step_python_type,
edges=self.process.edges,
state=process_state,
steps=steps_as_dicts,
)
return dapr_process_info.model_dump()
async def handle_message(self, message: ProcessMessage) -> None:
"""Handles a message."""
if message.target_event_id is None:
raise KernelException(
"Internal Process Error: The target event id must be specified when sending a message to a step."
)
event_id = message.target_event_id
if event_id in self.output_edges and self.output_edges[event_id] is not None:
for _ in self.output_edges[event_id]:
nested_event = KernelProcessEvent(
id=event_id, data=message.target_event_data, visibility=KernelProcessEventVisibility.Internal
)
await self.run_once(nested_event.model_dump_json())
async def _initialize_process_actor(
self, process_info: DaprProcessInfo, parent_process_id: str | None = None
) -> None:
"""Initializes the process actor."""
if process_info is None:
raise ValueError("The process info is not defined.")
if process_info.steps is None:
raise ValueError("The process info does not contain any steps.")
self.parent_process_id = parent_process_id
self.process = process_info
self.step_infos = list(self.process.steps)
self.output_edges = {kvp[0]: list(kvp[1]) for kvp in self.process.edges.items()}
for step in self.step_infos:
step_actor: StepInterface | None = None
# The current step should already have a name.
assert step.state and step.state.name is not None # nosec
if isinstance(step, DaprProcessInfo):
# The process will only have an Id if it's already been executed.
if not step.state.id:
step.state.id = str(uuid.uuid4().hex)
# Initialize the step as a process
scoped_process_id = self._scoped_actor_id(ActorId(step.state.id))
process_actor: ProcessInterface = ActorProxy.create( # type: ignore
actor_type=f"{ProcessActor.__name__}",
actor_id=scoped_process_id,
actor_interface=ProcessInterface,
)
process_payload: dict[str, Any] = {
"process_info": step.model_dump_json(),
"parent_process_id": self.id.id,
}
await process_actor.initialize_process(process_payload)
step_actor = ActorProxy.create( # type: ignore
actor_type=f"{ProcessActor.__name__}",
actor_id=scoped_process_id,
actor_interface=StepInterface,
)
else:
# The current step should already have an Id.
assert step.state and step.state.id is not None # nosec
scoped_step_id = self._scoped_actor_id(ActorId(step.state.id))
step_actor = ActorProxy.create( # type: ignore
actor_type=f"{StepActor.__name__}",
actor_id=scoped_step_id,
actor_interface=StepInterface,
)
assert step_actor is not None # nosec
step_dict = step.model_dump()
step_payload: dict[str, Any] = {"step_info": step_dict, "parent_process_id": self.id.id}
try:
await step_actor.initialize_step(json.dumps(step_payload))
except Exception as ex:
logger.error(f"Error initializing ProcessActor step: {ex}")
raise ex
# Add the local step to the list of steps
self.steps.append(step_actor) # type: ignore
self.initialize_task = True
def _scoped_actor_id(self, actor_id: ActorId, scope_to_parent: bool = False) -> ActorId:
"""Creates a scoped actor ID."""
if scope_to_parent and self.parent_process_id is None:
raise ValueError("The parent process Id must be set before scoping to the parent process.")
id = self.parent_process_id if scope_to_parent else self.id.id
return ActorId(f"{id}.{actor_id.id}")
async def internal_execute(self, max_supersteps: int = 100, keep_alive: bool = True):
"""Internal execution logic for the process."""
logger.debug(f"Running process for {max_supersteps} supersteps.")
try:
for _ in range(max_supersteps):
if await self._is_end_message_sent():
# Exit the loop without cancelling the task
break
# Check for external events
await self._enqueue_external_messages()
# Prepare incoming messages for each step
step_preparation_tasks = [step.prepare_incoming_messages() for step in self.steps]
message_counts = await asyncio.gather(*step_preparation_tasks)
if sum(message_counts) == 0 and (not keep_alive or self.external_event_queue.empty()):
# Exit the loop without cancelling the task
break
# Process the incoming messages for each step
step_processing_tasks = [step.process_incoming_messages() for step in self.steps]
await asyncio.gather(*step_processing_tasks)
# Handle public events
await self.send_outgoing_public_events()
except Exception as ex:
logger.error(f"An error occurred while running the process: {ex}")
raise
def _scoped_event(self, dapr_event: ProcessEvent):
if dapr_event is None:
raise ValueError("The Dapr event must be specified.")
if self.process is None or self.process.state is None:
raise ValueError("The process must be initialized before scoping the event.")
dapr_event.namespace = f"{self.name}_{self.process.state.id}"
return dapr_event
async def send_outgoing_public_events(self) -> None:
"""Sends outgoing public events."""
if self.parent_process_id is not None:
event_queue: EventBufferActor = ActorProxy.create( # type: ignore
actor_type=f"{EventBufferActor.__name__}",
actor_id=ActorId(self.id.id),
actor_interface=EventBufferInterface,
)
all_events: list[str] = await event_queue.dequeue_all()
process_events = [ProcessEvent.model_validate(json.loads(e)) for e in all_events]
for e in process_events:
scoped_event = self._scoped_event(e)
if scoped_event.id in self.output_edges and self.output_edges[scoped_event.id] is not None:
for edge in self.output_edges[scoped_event.id]:
message: ProcessMessage = ProcessMessageFactory.create_from_edge(edge, e.data)
scoped_message_buffer_id = self._scoped_actor_id(
ActorId(edge.output_target.step_id), scope_to_parent=True
)
message_queue: MessageBufferActor = ActorProxy.create( # type: ignore
actor_id=scoped_message_buffer_id,
actor_type=f"{MessageBufferActor.__name__}",
actor_interface=MessageBufferInterface,
)
message_json = json.dumps(message.model_dump())
logger.info(f"Enqueueing message: {message_json}")
await message_queue.enqueue(message_json)
async def _is_end_message_sent(self) -> bool:
"""Checks if the end message has been sent."""
scoped_message_buffer_id = self._scoped_actor_id(ActorId(END_PROCESS_ID))
end_message_queue: MessageBufferActor = ActorProxy.create( # type: ignore
actor_type=f"{MessageBufferActor.__name__}",
actor_id=scoped_message_buffer_id,
actor_interface=MessageBufferInterface,
)
messages: list[str] = await end_message_queue.dequeue_all()
logger.info(f"End message sent: {len(messages) > 0}")
return len(messages) > 0
async def _enqueue_external_messages(self) -> None:
"""Enqueues external messages into the process."""
external_event_queue: ExternalEventBufferActor = ActorProxy.create( # type: ignore
actor_type=f"{ExternalEventBufferActor.__name__}",
actor_id=ActorId(self.id.id),
actor_interface=ExternalEventBufferInterface,
)
external_events_json = await external_event_queue.dequeue_all()
logger.info(f"External events dequeued: {len(external_events_json)} with json: {external_events_json}")
external_events = [KernelProcessEvent.model_validate(json.loads(e)) for e in external_events_json]
for external_event in external_events:
if external_event.id in self.output_edges and self.output_edges[external_event.id] is not None:
for edge in self.output_edges[external_event.id]:
message: ProcessMessage = ProcessMessageFactory.create_from_edge(edge, external_event.data)
scoped_message_buffer_id = self._scoped_actor_id(ActorId(edge.output_target.step_id))
message_queue: MessageBufferActor = ActorProxy.create( # type: ignore
actor_type=f"{MessageBufferActor.__name__}",
actor_id=scoped_message_buffer_id,
actor_interface=MessageBufferInterface,
)
message_json = json.dumps(message.model_dump())
logger.info(f"Enqueueing message: {message_json}")
await message_queue.enqueue(message_json)
@@ -0,0 +1,480 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import logging
from collections.abc import Callable, Sequence
from inspect import isawaitable
from queue import Queue
from typing import Any
from dapr.actor import Actor, ActorId, ActorProxy
from dapr.actor.runtime.context import ActorRuntimeContext
from semantic_kernel.exceptions.kernel_exceptions import KernelException
from semantic_kernel.exceptions.process_exceptions import (
ProcessFunctionNotFoundException,
ProcessTargetFunctionNameMismatchException,
)
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.kernel import Kernel
from semantic_kernel.processes.dapr_runtime.actors.actor_state_key import ActorStateKeys
from semantic_kernel.processes.dapr_runtime.actors.event_buffer_actor import EventBufferActor
from semantic_kernel.processes.dapr_runtime.actors.message_buffer_actor import MessageBufferActor
from semantic_kernel.processes.dapr_runtime.dapr_step_info import DaprStepInfo
from semantic_kernel.processes.dapr_runtime.interfaces.event_buffer_interface import EventBufferInterface
from semantic_kernel.processes.dapr_runtime.interfaces.message_buffer_interface import MessageBufferInterface
from semantic_kernel.processes.dapr_runtime.interfaces.step_interface import StepInterface
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.kernel_process.kernel_process_event import (
KernelProcessEvent,
KernelProcessEventVisibility,
)
from semantic_kernel.processes.kernel_process.kernel_process_message_channel import KernelProcessMessageChannel
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.processes.process_event import ProcessEvent
from semantic_kernel.processes.process_message import ProcessMessage
from semantic_kernel.processes.process_message_factory import ProcessMessageFactory
from semantic_kernel.processes.process_types import get_generic_state_type
from semantic_kernel.processes.step_utils import (
DEFAULT_ALLOWED_MODULE_PREFIXES,
find_input_channels,
get_fully_qualified_name,
get_step_class_from_qualified_name,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class StepActor(Actor, StepInterface, KernelProcessMessageChannel):
"""Represents a step actor that follows the Step abstract class."""
def __init__(
self,
ctx: ActorRuntimeContext,
actor_id: ActorId,
kernel: Kernel,
factories: dict[str, Callable],
allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES,
):
"""Initializes a new instance of StepActor.
Args:
ctx: The actor runtime context.
actor_id: The unique ID for the actor.
kernel: The Kernel dependency to be injected.
factories: The factory dictionary to use for creating the step.
allowed_module_prefixes: Sequence of module prefixes that are allowed
for step class loading. Step classes must come from modules starting
with one of these prefixes. Defaults to ("semantic_kernel.",). Pass
None to allow any module (not recommended for production).
"""
super().__init__(ctx, actor_id)
self.kernel = kernel
self.factories: dict[str, Callable] = factories
self.allowed_module_prefixes: Sequence[str] | None = allowed_module_prefixes
self.parent_process_id: str | None = None
self.step_info: DaprStepInfo | None = None
self.initialize_task: bool | None = False
self.event_namespace: str | None = None
self.inner_step_type: str | None = None
self.incoming_messages: Queue = Queue()
self.step_state: KernelProcessStepState | None = None
self.step_state_type: type | None = None
self.output_edges: dict[str, list[KernelProcessEdge]] = {}
self.functions: dict[str, KernelFunction] = {}
self.inputs: dict[str, dict[str, Any | None]] = {}
self.initial_inputs: dict[str, dict[str, Any | None]] = {}
self.init_lock: asyncio.Lock = asyncio.Lock()
self.step_activated: bool = False
@property
def name(self) -> str:
"""Gets the name of the step."""
if self.step_info is None or self.step_info.state is None or self.step_info.state.name is None:
error_message = "The step must be initialized before accessing the name property."
logger.error(error_message)
raise KernelException(error_message)
return self.step_info.state.name
async def initialize_step(self, input: str) -> None:
"""Initializes the step with the provided step information."""
if input is None:
raise ValueError("step_info must not be None")
if self.initialize_task:
return
try:
input_dict: dict[str, Any] = json.loads(input)
except (json.JSONDecodeError, TypeError):
raise ValueError("Input must be a valid JSON string representing a dictionary")
step_info = DaprStepInfo.model_validate(input_dict.get("step_info"))
await self._int_initialize_step(step_info, input_dict.get("parent_process_id"))
try:
await self._state_manager.try_add_state(ActorStateKeys.StepInfoState.value, step_info.model_dump_json())
await self._state_manager.try_add_state(
ActorStateKeys.StepParentProcessId.value, input_dict.get("parent_process_id")
)
await self._state_manager.save_state()
except Exception as ex:
logger.error(f"Error in Step {self.name}: {ex!s}")
raise ex
async def _int_initialize_step(self, step_info: DaprStepInfo, parent_process_id: str | None = None) -> None:
"""Internal method to initialize the step with the provided step information.
Args:
step_info: The DaprStepInfo object to initialize the step with.
parent_process_id: Optional parent process ID if one exists.
"""
self.inner_step_type = step_info.inner_step_python_type
self.parent_process_id = parent_process_id
self.step_info = step_info
self.step_state = self.step_info.state
self.output_edges = {k: v for k, v in step_info.edges.items()}
self.event_namespace = f"{self.step_info.state.name}_{self.step_info.state.id}"
self.initialize_task = True
async def prepare_incoming_messages(self) -> int:
"""Prepares the incoming messages for processing."""
try:
message_queue: MessageBufferInterface = ActorProxy.create( # type: ignore
actor_type=f"{MessageBufferActor.__name__}",
actor_id=ActorId(self.id.id),
actor_interface=MessageBufferInterface,
)
incoming = await message_queue.dequeue_all()
messages = []
for message in incoming:
process_message = ProcessMessage.model_validate(json.loads(message))
messages.append(process_message)
for msg in messages:
self.incoming_messages.put(msg)
await self._state_manager.try_add_state(
ActorStateKeys.StepIncomingMessagesState.value,
incoming,
)
await self._state_manager.save_state()
return self.incoming_messages.qsize() if self.incoming_messages else 0
except Exception as e:
error_message = str(e)
logger.error(f"Error in prepare_incoming_messages: {error_message}")
raise Exception(error_message)
async def process_incoming_messages(self):
"""Processes the incoming messages for the step."""
while not self.incoming_messages.empty():
message = self.incoming_messages.get()
await self.handle_message(message)
messages_to_save = [json.dumps(msg.model_dump()) for msg in list(self.incoming_messages.queue)]
await self._state_manager.try_add_state(ActorStateKeys.StepIncomingMessagesState.value, messages_to_save)
await self._state_manager.save_state()
async def activate_step(self):
"""Initializes the step."""
# Instantiate an instance of the inner step object and retrieve its class reference.
if self.factories and self.inner_step_type in self.factories:
step_object = self.factories[self.inner_step_type]()
if isawaitable(step_object):
step_object = await step_object
step_cls = step_object.__class__
step_instance: KernelProcessStep = step_object # type: ignore
else:
step_cls = get_step_class_from_qualified_name(
self.inner_step_type,
allowed_module_prefixes=self.allowed_module_prefixes,
)
step_instance: KernelProcessStep = step_cls() # type: ignore
kernel_plugin = self.kernel.add_plugin(
step_instance,
self.step_info.state.name if self.step_info.state else "default_name",
)
# Load the kernel functions.
for name, f in kernel_plugin.functions.items():
self.functions[name] = f
# Initialize the input channels.
self.initial_inputs = find_input_channels(channel=self, functions=self.functions)
self.inputs = {k: {kk: vv for kk, vv in v.items()} if v else {} for k, v in self.initial_inputs.items()}
# Use the existing state or create a new one if not provided.
state_object = self.step_info.state
# Extract TState from inner_step_type using the class reference.
t_state = get_generic_state_type(step_cls)
if t_state is not None:
state_type = KernelProcessStepState[t_state]
if state_object is None:
# Create a fresh step state object if none is provided.
state_object = state_type(
name=step_cls.__name__,
id=step_cls.__name__,
state=None,
)
else:
# Ensure that state_object is an instance of the expected type.
if not isinstance(state_object, KernelProcessStepState):
error_message = "State object is not of the expected KernelProcessStepState type."
raise KernelException(error_message)
await self._state_manager.try_add_state(
ActorStateKeys.StepStateType.value,
get_fully_qualified_name(t_state),
)
await self._state_manager.try_add_state(
ActorStateKeys.StepStateJson.value,
json.dumps(state_object.model_dump()),
)
await self._state_manager.save_state()
# If state is None, instantiate it. If it exists but is not the right type, validate it.
if state_object.state is None:
try:
state_object.state = t_state()
except Exception as e:
error_message = f"Cannot instantiate state of type {t_state}: {e}"
raise KernelException(error_message)
else:
# Convert the existing state if it's not already an instance of t_state
if not isinstance(state_object.state, t_state):
try:
state_object.state = t_state.model_validate(state_object.state)
except Exception as e:
error_message = f"Cannot validate state of type {t_state}: {e}"
raise KernelException(error_message)
else:
# The step has no user-defined state; use the base KernelProcessStepState.
state_type = KernelProcessStepState
if state_object is None:
state_object = state_type(
name=step_cls.__name__,
id=step_cls.__name__,
state=None,
)
if state_object is None:
error_message = "The state object for the KernelProcessStep could not be created."
raise KernelException(error_message)
# Set the step state and activate the step with the state object.
self.step_state = state_object
await step_instance.activate(state_object)
async def handle_message(self, message: ProcessMessage):
"""Handles a LocalMessage that has been sent to the step."""
if message is None:
raise ValueError("The message is None.")
logger.info(f"Received message from `{message.source_id}` targeting function `{message.function_name}`.")
if not self.step_activated:
async with self.init_lock:
# Second check to ensure that initialization happens only once
# This avoids a race condition where multiple coroutines might
# reach the first check at the same time before any of them acquire the lock.
if not self.step_activated:
await self.activate_step()
self.step_activated = True
if self.functions is None or self.inputs is None or self.initial_inputs is None:
logger.error(f"The step `{self.name}` has not been initialized.")
raise ValueError("The step has not been initialized.")
message_log_parameters = ", ".join(f"{k}: {v}" for k, v in message.values.items())
logger.info(
f"Received message from `{message.source_id}` targeting function "
f"`{message.function_name}` and parameters `{message_log_parameters}`."
)
# Add the message values to the inputs for the function
for k, v in message.values.items():
if self.inputs.get(message.function_name) and self.inputs[message.function_name].get(k):
logger.info(
f"Step {self.name} already has input for `{message.function_name}.{k}`, "
f"it is being overwritten with a message from Step named `{message.source_id}`."
)
if message.function_name not in self.inputs:
self.inputs[message.function_name] = {}
self.inputs[message.function_name][k] = v
invocable_functions = [
k
for k, v in self.inputs.items()
if v is not None and (v == {} or all(val is not None for val in v.values()))
]
missing_keys = [
f"{outer_key}.{inner_key}"
for outer_key, outer_value in self.inputs.items()
for inner_key, inner_value in outer_value.items()
if inner_value is None
]
if not invocable_functions:
logger.info(f"No invocable functions, missing keys: {', '.join(missing_keys)}")
return
target_function = next((name for name in invocable_functions if name == message.function_name), None)
if not target_function:
raise ProcessTargetFunctionNameMismatchException(
f"A message targeting function `{message.function_name}` has resulted in a different function "
f"`{invocable_functions[0]}` becoming invocable. Check the function names."
)
logger.info(
f"Step with Id '{self.id}' received all required input for function [{target_function}] and is executing."
)
# Concatenate all inputs and run the function
arguments = self.inputs[target_function]
function = self.functions.get(target_function)
if function is None:
raise ProcessFunctionNotFoundException(f"Function {target_function} not found in plugin {self.name}")
invoke_result = None
event_name: str = ""
event_value = None
try:
logger.info(
f"Invoking plugin `{function.plugin_name}` and function `{function.name}` with arguments: {arguments}"
)
invoke_result = await self.invoke_function(function, self.kernel, arguments)
if invoke_result is None:
raise KernelException(f"Function {target_function} returned None.")
event_name = f"{target_function}.OnResult"
event_value = invoke_result.value
if self.step_state is not None:
state_dict = self.step_state.model_dump()
await self._state_manager.set_state(ActorStateKeys.StepStateJson.value, json.dumps(state_dict))
await self._state_manager.save_state()
except Exception as ex:
logger.error(f"Error in Step {self.name}: {ex!s}")
event_name = f"{target_function}.OnError"
event_value = str(ex)
finally:
await self.emit_event(KernelProcessEvent(id=event_name, data=event_value))
# Reset the inputs for the function that was just executed
self.inputs[target_function] = self.initial_inputs.get(target_function, {}).copy()
async def invoke_function(self, function: "KernelFunction", kernel: "Kernel", arguments: dict[str, Any]):
"""Invokes the function."""
return await kernel.invoke(function, **arguments)
async def emit_event(self, process_event: KernelProcessEvent):
"""Emits an event from the step."""
if self.event_namespace is None:
raise ValueError("The event namespace must be initialized before emitting an event.")
await self.emit_process_event(ProcessEvent(inner_event=process_event, namespace=self.event_namespace))
async def emit_process_event(self, dapr_event: ProcessEvent):
"""Emits an event from the step."""
if dapr_event.visibility == KernelProcessEventVisibility.Public and self.parent_process_id is not None:
parent_process: EventBufferActor = ActorProxy.create( # type: ignore
actor_type=f"{EventBufferActor.__name__}",
actor_id=ActorId(self.parent_process_id),
actor_interface=EventBufferInterface,
)
await parent_process.enqueue(dapr_event.model_dump_json())
for edge in self.get_edge_for_event(dapr_event.id):
message: ProcessMessage = ProcessMessageFactory.create_from_edge(edge, dapr_event.data)
scoped_step_id = self._scoped_actor_id(ActorId(edge.output_target.step_id))
target_step: MessageBufferInterface = ActorProxy.create( # type: ignore
actor_type=f"{MessageBufferActor.__name__}",
actor_id=scoped_step_id,
actor_interface=MessageBufferInterface,
)
await target_step.enqueue(message.model_dump_json())
async def to_dapr_step_info(self) -> dict:
"""Converts the step to a DaprStepInfo object."""
if not self.step_activated:
async with self.init_lock:
# Second check to ensure that initialization happens only once
# This avoids a race condition where multiple coroutines might
# reach the first check at the same time before any of them acquire the lock.
if not self.step_activated:
await self.activate_step()
self.step_activated = True
if self.step_info is None:
raise ValueError("The step must be initialized before converting to DaprStepInfo.")
if self.inner_step_type is None:
raise ValueError("The inner step type must be initialized before converting to DaprStepInfo.")
if self.step_state is not None:
self.step_info.state = self.step_state
step_info = DaprStepInfo(
inner_step_python_type=self.inner_step_type,
state=self.step_info.state,
edges=self.step_info.edges,
)
return step_info.model_dump()
async def _on_activate(self) -> None:
"""Override the Actor's on_activate method."""
try:
has_value, existing_step_info = await self._state_manager.try_get_state(ActorStateKeys.StepInfoState.value)
except Exception as ex:
logger.error(f"Error in Step {self.name}: {ex!s}")
raise ex
if has_value:
parent_process_id = await self._state_manager.get_state(ActorStateKeys.StepParentProcessId.value)
step_info = DaprStepInfo.model_validate(json.loads(existing_step_info)) # type: ignore
await self._int_initialize_step(step_info, parent_process_id=parent_process_id)
# Load persisted incoming messages
has_value, incoming_messages = await self._state_manager.try_get_state(
ActorStateKeys.StepIncomingMessagesState.value
)
if has_value:
messages = json.loads(incoming_messages) # type: ignore
for msg in messages:
process_message = ProcessMessage.model_validate(json.loads(msg))
self.incoming_messages.put(process_message)
def scoped_event(self, dapr_event: "ProcessEvent") -> "ProcessEvent":
"""Generates a scoped event for the step."""
if dapr_event is None:
raise ValueError("The Dapr event must be specified.")
dapr_event.namespace = f"{self.name}_{self.id.id}"
return dapr_event
def _scoped_actor_id(self, actor_id: ActorId) -> ActorId:
"""Generates a scoped actor ID for the step."""
return ActorId(f"{self.parent_process_id}.{actor_id.id}")
def get_edge_for_event(self, event_id: str) -> list["KernelProcessEdge"]:
"""Retrieves all edges that are associated with the provided event Id."""
if not self.output_edges:
return []
return self.output_edges.get(event_id, [])