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,9 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.processes.process_builder import ProcessBuilder
from semantic_kernel.processes.process_types import TState
__all__ = [
"ProcessBuilder",
"TState",
]
+15
View File
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from semantic_kernel.utils.feature_stage_decorator import experimental
END_PROCESS_ID: str = "Microsoft.SemanticKernel.Process.EndStep"
@experimental
class ProcessSupportedComponents(str, Enum):
"""Supported Process Components."""
Step = "Step"
Process = "Process"
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
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.process_actor import ProcessActor
from semantic_kernel.processes.dapr_runtime.actors.step_actor import StepActor
from semantic_kernel.processes.dapr_runtime.dapr_actor_registration import (
register_fastapi_dapr_actors,
register_flask_dapr_actors,
)
from semantic_kernel.processes.dapr_runtime.dapr_kernel_process import start
__all__ = [
"EventBufferActor",
"ExternalEventBufferActor",
"MessageBufferActor",
"ProcessActor",
"StepActor",
"register_fastapi_dapr_actors",
"register_flask_dapr_actors",
"start",
]
@@ -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, [])
@@ -0,0 +1,66 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from typing import TYPE_CHECKING
from dapr.actor import ActorId
from dapr.actor.runtime.context import ActorRuntimeContext
from dapr.ext.fastapi import DaprActor as FastAPIDaprActor
from flask_dapr.actor import DaprActor as FlaskDaprActor
from semantic_kernel.processes.dapr_runtime import (
EventBufferActor,
ExternalEventBufferActor,
MessageBufferActor,
ProcessActor,
StepActor,
)
if TYPE_CHECKING:
from semantic_kernel.kernel import Kernel
def create_actor_factories(kernel: "Kernel", factories: dict[str, Callable] | None = None) -> tuple:
"""Creates actor factories for ProcessActor and StepActor."""
if factories is None:
factories = {}
def process_actor_factory(
ctx: ActorRuntimeContext,
actor_id: ActorId,
) -> ProcessActor:
return ProcessActor(ctx, actor_id, kernel=kernel, factories=factories)
def step_actor_factory(
ctx: ActorRuntimeContext,
actor_id: ActorId,
) -> StepActor:
return StepActor(ctx, actor_id, kernel=kernel, factories=factories)
return process_actor_factory, step_actor_factory
# Asynchronous registration for FastAPI
async def register_fastapi_dapr_actors(
actor: FastAPIDaprActor, kernel: "Kernel", factories: dict[str, Callable] | None = None
) -> None:
"""Registers the actors with the Dapr runtime for use with a FastAPI app."""
process_actor_factory, step_actor_factory = create_actor_factories(kernel, factories)
await actor.register_actor(ProcessActor, actor_factory=process_actor_factory)
await actor.register_actor(StepActor, actor_factory=step_actor_factory)
await actor.register_actor(EventBufferActor)
await actor.register_actor(MessageBufferActor)
await actor.register_actor(ExternalEventBufferActor)
# Synchronous registration for Flask
def register_flask_dapr_actors(
actor: FlaskDaprActor, kernel: "Kernel", factory: dict[str, Callable] | None = None
) -> None:
"""Registers the actors with the Dapr runtime for use with a Flask app."""
process_actor_factory, step_actor_factory = create_actor_factories(kernel, factory)
actor.register_actor(ProcessActor, actor_factory=process_actor_factory)
actor.register_actor(StepActor, actor_factory=step_actor_factory)
actor.register_actor(EventBufferActor)
actor.register_actor(MessageBufferActor)
actor.register_actor(ExternalEventBufferActor)
@@ -0,0 +1,52 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from typing import TYPE_CHECKING
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
from semantic_kernel.processes.dapr_runtime.dapr_kernel_process_context import DaprKernelProcessContext
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
@experimental
async def start(
process: "KernelProcess",
initial_event: KernelProcessEvent | str | Enum,
process_id: str | None = None,
max_supersteps: int | None = None,
**kwargs,
) -> DaprKernelProcessContext:
"""Start the kernel process.
Args:
process: The kernel process to start.
initial_event: The initial event to start the process with.
process_id: The ID of the process. If None, a new ID will be generated.
max_supersteps: The maximum number of supersteps. This is the total number of times process steps will run.
Defaults to None, and thus the process will run its steps 100 times.
**kwargs: Additional keyword arguments.
"""
if process is None:
raise ProcessInvalidConfigurationException("process cannot be None")
if process.state is None:
raise ProcessInvalidConfigurationException("process state cannot be empty")
if initial_event is None:
raise ProcessInvalidConfigurationException("initial_event cannot be None")
initial_event_str: str | KernelProcessEvent = (
initial_event.value if isinstance(initial_event, Enum) else initial_event
)
if isinstance(initial_event_str, str):
initial_event_str = KernelProcessEvent(id=initial_event_str, data=kwargs.get("data"))
if process_id is not None:
process.state.id = process_id
process_context = DaprKernelProcessContext(process=process, max_supersteps=max_supersteps)
await process_context.start_with_event(initial_event_str)
return process_context
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
import uuid
from collections.abc import Sequence
from dapr.actor import ActorId, ActorProxy
from semantic_kernel.processes.dapr_runtime.actors.process_actor import ProcessActor
from semantic_kernel.processes.dapr_runtime.dapr_process_info import DaprProcessInfo
from semantic_kernel.processes.dapr_runtime.interfaces.process_interface import ProcessInterface
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
from semantic_kernel.processes.step_utils import DEFAULT_ALLOWED_MODULE_PREFIXES
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DaprKernelProcessContext:
"""A Dapr kernel process context."""
dapr_process: ProcessInterface
process: KernelProcess
max_supersteps: int = 100
def __init__(
self,
process: KernelProcess,
max_supersteps: int | None = None,
allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES,
) -> None:
"""Initialize a new instance of DaprKernelProcessContext.
Args:
process: The kernel process to start.
max_supersteps: The maximum number of supersteps. This is the total number of times process steps will run.
Defaults to None, and thus the process will run its steps 100 times.
allowed_module_prefixes: Sequence of module prefixes that are allowed
for step class loading. Defaults to ("semantic_kernel.",). Pass
None to allow any module (not recommended for production).
"""
if process.state.name is None:
raise ValueError("Process state name must not be None")
if process.state.id is None or process.state.id == "":
process.state.id = str(uuid.uuid4().hex)
if max_supersteps is not None:
self.max_supersteps = max_supersteps
self.allowed_module_prefixes = allowed_module_prefixes
self.process = process
process_id = ActorId(process.state.id)
self.dapr_process = ActorProxy.create( # type: ignore
actor_type=f"{ProcessActor.__name__}",
actor_id=process_id,
actor_interface=ProcessInterface,
)
async def start_with_event(self, initial_event: KernelProcessEvent) -> None:
"""Starts the process with the provided initial event."""
dapr_process = DaprProcessInfo.from_kernel_process(self.process)
dapr_process_dict = dapr_process.model_dump_json()
payload = {
"process_info": dapr_process_dict,
"parent_process_id": None,
"max_supersteps": self.max_supersteps,
}
await self.dapr_process.initialize_process(payload)
initial_event_json = initial_event.model_dump_json()
await self.dapr_process.run_once(initial_event_json)
async def send_event(self, event: KernelProcessEvent) -> None:
"""Sends an event to the process."""
await self.dapr_process.send_message(event)
async def stop(self) -> None:
"""Stops the process."""
await self.dapr_process.stop()
async def get_state(self) -> KernelProcess:
"""Retrieves the current state of the process.
Returns:
The current state of the process.
"""
raw_process_info = await self.dapr_process.get_process_info()
dapr_process_info = DaprProcessInfo.model_validate(raw_process_info)
return dapr_process_info.to_kernel_process(
allowed_module_prefixes=self.allowed_module_prefixes,
)
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import MutableSequence, Sequence
from typing import Literal
from pydantic import Field
from semantic_kernel.processes.dapr_runtime.dapr_step_info import DaprStepInfo
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.step_utils import DEFAULT_ALLOWED_MODULE_PREFIXES
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DaprProcessInfo(DaprStepInfo):
"""A Dapr process info."""
type: Literal["DaprProcessInfo"] = "DaprProcessInfo" # type: ignore
steps: MutableSequence["DaprStepInfo | DaprProcessInfo"] = Field(default_factory=list)
def to_kernel_process(
self, allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES
) -> KernelProcess:
"""Converts the Dapr process info to a kernel process.
Args:
allowed_module_prefixes: Sequence of module prefixes that are allowed
for step class loading. Defaults to DEFAULT_ALLOWED_MODULE_PREFIXES
("semantic_kernel.",). Pass None to disable the allowlist and allow
any module (not recommended for production). An empty sequence blocks
all modules.
"""
if not isinstance(self.state, KernelProcessState):
raise ValueError("State must be a kernel process state")
steps: list[KernelProcessStepInfo] = []
for step in self.steps:
if isinstance(step, DaprProcessInfo):
steps.append(step.to_kernel_process(allowed_module_prefixes=allowed_module_prefixes))
else:
steps.append(step.to_kernel_process_step_info(allowed_module_prefixes=allowed_module_prefixes))
return KernelProcess(state=self.state, steps=steps, edges=self.edges)
@classmethod
def from_kernel_process(cls, kernel_process: KernelProcess) -> "DaprProcessInfo":
"""Creates a Dapr process info from a kernel process."""
if kernel_process is None:
raise ValueError("Kernel process must be provided")
dapr_step_info = DaprStepInfo.from_kernel_step_info(kernel_process)
dapr_steps: MutableSequence[DaprProcessInfo | DaprStepInfo] = []
for step in kernel_process.steps:
if isinstance(step, KernelProcess):
dapr_steps.append(DaprProcessInfo.from_kernel_process(step))
else:
dapr_steps.append(DaprStepInfo.from_kernel_step_info(step))
return DaprProcessInfo(
inner_step_python_type=dapr_step_info.inner_step_python_type,
state=dapr_step_info.state,
edges={key: list(value) for key, value in dapr_step_info.edges.items()},
steps=dapr_steps,
)
@@ -0,0 +1,61 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from typing import Literal
from pydantic import Field
from semantic_kernel.exceptions.kernel_exceptions import KernelException
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.processes.step_utils import (
DEFAULT_ALLOWED_MODULE_PREFIXES,
get_fully_qualified_name,
get_step_class_from_qualified_name,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DaprStepInfo(KernelBaseModel):
"""A Dapr step info."""
type: Literal["DaprStepInfo"] = "DaprStepInfo"
inner_step_python_type: str
state: KernelProcessState | KernelProcessStepState
edges: dict[str, list[KernelProcessEdge]] = Field(default_factory=dict)
def to_kernel_process_step_info(
self, allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES
) -> KernelProcessStepInfo:
"""Converts the Dapr step info to a kernel process step info.
Args:
allowed_module_prefixes: Sequence of module prefixes that are allowed
for step class loading. Defaults to DEFAULT_ALLOWED_MODULE_PREFIXES
("semantic_kernel.",). Pass None to disable the allowlist and allow
any module (not recommended for production). An empty sequence blocks
all modules.
"""
inner_step_type = get_step_class_from_qualified_name(
self.inner_step_python_type,
allowed_module_prefixes=allowed_module_prefixes,
)
return KernelProcessStepInfo(inner_step_type=inner_step_type, state=self.state, output_edges=self.edges)
@classmethod
def from_kernel_step_info(cls, kernel_step_info: KernelProcessStepInfo) -> "DaprStepInfo":
"""Creates a Dapr step info from a kernel step info."""
if kernel_step_info is None:
raise KernelException("Kernel step info must be provided")
inner_step_type = get_fully_qualified_name(kernel_step_info.inner_step_type)
return DaprStepInfo(
inner_step_python_type=inner_step_type,
state=kernel_step_info.state,
edges={key: list(value) for key, value in kernel_step_info.edges.items()},
)
@@ -0,0 +1,32 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from dapr.actor import ActorInterface, actormethod
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class EventBufferInterface(ActorInterface, ABC):
"""Abstract base class for an event buffer that follows the ActorInterface."""
@abstractmethod
@actormethod(name="enqueue")
async def enqueue(self, step_event: str) -> None:
"""Enqueues a `ProcessEvent` step event into the buffer.
Args:
step_event: The step event to enqueue.
"""
...
@abstractmethod
@actormethod(name="dequeue_all")
async def dequeue_all(self) -> list[str]:
"""Dequeues a step event from the buffer.
Returns:
The dequeued step event as a list of `ProcessEvent`.
"""
...
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from dapr.actor import ActorInterface, actormethod
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ExternalEventBufferInterface(ActorInterface, ABC):
"""Abstract base class for an external event buffer that follows the ActorInterface."""
@abstractmethod
@actormethod(name="enqueue")
async def enqueue(self, external_event: str) -> None:
"""Enqueues an external event into the buffer.
Args:
external_event: The external event to enqueue.
"""
...
@abstractmethod
@actormethod(name="dequeue_all")
async def dequeue_all(self) -> list[str]:
"""Dequeues all external events from the buffer as.
The list is of string representations of KernelProcessEvent.
Returns:
The dequeued external event.
"""
...
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from dapr.actor import ActorInterface, actormethod
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class MessageBufferInterface(ActorInterface, ABC):
"""Abstract base class for a message event buffer that follows the ActorInterface."""
@abstractmethod
@actormethod(name="enqueue")
async def enqueue(self, message: str) -> None:
"""Enqueues a message event into the buffer.
Args:
message: The message event to enqueue.
"""
...
@abstractmethod
@actormethod(name="dequeue_all")
async def dequeue_all(self) -> list[str]:
"""Dequeues all process events from the buffer.
Returns:
The dequeued message event as a list of string
representing a ProcessEvent.
"""
...
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from dapr.actor import ActorInterface, actormethod
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
@experimental
class ProcessInterface(ActorInterface, ABC):
"""Abstract base class for a process that follows the ActorInterface."""
@abstractmethod
@actormethod(name="initialize_process")
async def initialize_process(self, input: dict) -> None:
"""Initializes the process with the specified instance of DaprProcessInfo.
:param process_info: Used to initialize the process.
:param parent_process_id: The parent ID of the process if one exists.
"""
...
@abstractmethod
@actormethod(name="start")
async def start(self, keep_alive: bool) -> None:
"""Starts an initialized process.
:param keep_alive: Indicates if the process should wait for external events after it's finished processing.
"""
...
@abstractmethod
@actormethod(name="run_once")
async def run_once(self, process_event: "KernelProcessEvent | str | None") -> None:
"""Starts the process with an initial event and then waits for the process to finish.
:param process_event: Required. The KernelProcessEvent to start the process with.
"""
...
@abstractmethod
@actormethod(name="stop")
async def stop(self) -> None:
"""Stops a running process, canceling and waiting for it to complete before returning."""
...
@abstractmethod
@actormethod(name="send_message")
async def send_message(self, process_event: "KernelProcessEvent") -> None:
"""Sends a message to the process without starting it if it is not already running.
:param process_event: Required. The KernelProcessEvent to queue for the process.
"""
...
@abstractmethod
@actormethod(name="get_process_info")
async def get_process_info(self) -> dict:
"""Retrieves the process information as a dict of DaprProcessInfo.
:return: An instance of DaprProcessInfo.
"""
...
@@ -0,0 +1,49 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from dapr.actor import ActorInterface, actormethod
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class StepInterface(ActorInterface, ABC):
"""Abstract base class for a step in the process workflow."""
@abstractmethod
@actormethod(name="initialize_step")
async def initialize_step(self, input: str) -> None:
"""Initializes the step with the provided step information.
Args:
input: the DaprStepinfo and ParentProcessId dictionary as a str
"""
...
@abstractmethod
@actormethod(name="prepare_incoming_messages")
async def prepare_incoming_messages(self) -> int:
"""Triggers the step to dequeue all pending messages and prepare for processing.
Returns:
The number of messages that were dequeued.
"""
...
@abstractmethod
@actormethod(name="process_incoming_messages")
async def process_incoming_messages(self) -> None:
"""Triggers the step to process all prepared messages."""
...
@abstractmethod
@actormethod(name="to_dapr_step_info")
async def to_dapr_step_info(self) -> dict:
"""Builds the current state of the step into a DaprStepInfo.
Returns:
The DaprStepInfo representing the current state of the step.
"""
...
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
from semantic_kernel.processes.kernel_process.kernel_process_event import (
KernelProcessEvent,
KernelProcessEventVisibility,
)
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
from semantic_kernel.processes.kernel_process.kernel_process_step_context import KernelProcessStepContext
from semantic_kernel.processes.kernel_process.kernel_process_step_metadata import kernel_process_step_metadata
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.processes.kernel_process.kernel_process_step_state_metadata import KernelProcessStateMetadata
__all__ = [
"KernelProcess",
"KernelProcessEvent",
"KernelProcessEventVisibility",
"KernelProcessStateMetadata",
"KernelProcessStep",
"KernelProcessStepContext",
"KernelProcessStepState",
"kernel_process_step_metadata",
]
@@ -0,0 +1,66 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from pydantic import Field
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.process_state_metadata_utils import kernel_process_to_process_state_metadata
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.kernel_process.kernel_process_step_state_metadata import KernelProcessStateMetadata
@experimental
class KernelProcess(KernelProcessStepInfo):
"""A kernel process."""
steps: list[KernelProcessStepInfo] = Field(default_factory=list)
factories: dict[str, Callable] = Field(default_factory=dict)
def __init__(
self,
state: KernelProcessState,
steps: list[KernelProcessStepInfo],
edges: dict[str, list["KernelProcessEdge"]] | None = None,
factories: dict[str, Callable] | None = None,
):
"""Initialize the kernel process.
Args:
state: The state of the process.
steps: The steps of the process.
edges: The edges of the process. Defaults to None.
factories: The factories of the process. This allows for the creation of
steps that require complex dependencies that cannot be JSON serialized or deserialized.
"""
if not state:
raise ValueError("state cannot be None")
if not steps:
raise ValueError("steps cannot be None")
if not state.name:
raise ValueError("state.Name cannot be None")
process_steps = []
process_steps.extend(steps)
args: dict[str, Any] = {
"steps": process_steps,
"inner_step_type": KernelProcess,
"state": state,
"output_edges": edges or {},
}
if factories:
args["factories"] = factories
super().__init__(**args)
def to_process_state_metadata(self) -> "KernelProcessStateMetadata":
"""Converts a kernel process to process state metadata."""
return kernel_process_to_process_state_metadata(self)
@@ -0,0 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_function_target import KernelProcessFunctionTarget
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessEdge(KernelBaseModel):
"""Represents an edge between steps."""
source_step_id: str
output_target: KernelProcessFunctionTarget
@@ -0,0 +1,32 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from typing import Any
from pydantic import ConfigDict
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessEventVisibility(Enum):
"""Visibility of a kernel process event."""
# The event is visible inside the process as well as outside the process. This is useful
# when the event is intended to be consumed by other processes or external systems.
Public = "Public"
# The event is only visible to steps within the same process.
Internal = "Internal"
@experimental
class KernelProcessEvent(KernelBaseModel):
"""A kernel process event."""
id: str
data: Any | None = None
visibility: KernelProcessEventVisibility = KernelProcessEventVisibility.Internal
model_config = ConfigDict(use_enum_values=False)
@@ -0,0 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessFunctionTarget(KernelBaseModel):
"""The target of a function call in a kernel process."""
step_id: str
function_name: str
parameter_name: str | None = None
target_event_id: str | None = None
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from semantic_kernel.processes.local_runtime.local_event import KernelProcessEvent
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessMessageChannel(ABC):
"""Abstract base class for emitting events from a step."""
@abstractmethod
async def emit_event(self, process_event: "KernelProcessEvent") -> None:
"""Emits the specified event from the step."""
pass
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Literal
from pydantic import Field
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessState(KernelProcessStepState):
"""The state of a kernel process."""
type: Literal["KernelProcessState"] = Field(default="KernelProcessState") # type: ignore
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC
from typing import TYPE_CHECKING, Generic, TypeVar
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
TState = TypeVar("TState")
@experimental
class KernelProcessStep(ABC, KernelBaseModel, Generic[TState]):
"""A KernelProcessStep Base class for process steps."""
state: TState | None = None
async def activate(self, state: "KernelProcessStepState[TState]"):
"""Activates the step and sets the state."""
pass # pragma: no cover
@@ -0,0 +1,42 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from semantic_kernel.exceptions.process_exceptions import ProcessEventUndefinedException
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_message_channel import KernelProcessMessageChannel
from semantic_kernel.processes.local_runtime.local_event import KernelProcessEvent
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessStepContext(KernelBaseModel):
"""The context of a step in a kernel process."""
step_message_channel: KernelProcessMessageChannel
def __init__(self, channel: KernelProcessMessageChannel):
"""Initialize the step context."""
super().__init__(step_message_channel=channel) # type: ignore
async def emit_event(self, process_event: "KernelProcessEvent | str | Enum | None", **kwargs) -> None:
"""Emit an event from the current step.
It is possible to either specify a `KernelProcessEvent` object or the ID of the event
along with the `data` and optional `visibility` keyword arguments.
Args:
process_event (KernelProcessEvent | str): The event to emit.
**kwargs: Additional keyword arguments to pass to the event.
"""
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
if process_event is None:
raise ProcessEventUndefinedException("Process event cannot be None")
if not isinstance(process_event, KernelProcessEvent):
process_event = KernelProcessEvent(
id=process_event.value if isinstance(process_event, Enum) else process_event, **kwargs
)
await self.step_message_channel.emit_event(process_event)
@@ -0,0 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KernelProcessStepInfo(KernelBaseModel):
"""Information about a step in a kernel process."""
inner_step_type: type
state: KernelProcessStepState
output_edges: dict[str, list[KernelProcessEdge]]
@property
def edges(self) -> dict[str, list["KernelProcessEdge"]]:
"""The edges of the step."""
return {k: v.copy() for k, v in self.output_edges.items()}
@@ -0,0 +1,37 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@dataclass(frozen=True)
class KernelProcessStepMetadataAttribute:
"""Metadata describing the version of the Step's implementation for serialization and replay."""
version: str = "v1"
@experimental
def kernel_process_step_metadata(version: str = "v1"):
"""Decorator to attach a version string representing the Step's implementation version.
This version is serialized in `versionInfo` for each step, enabling replay
and process recovery to instantiate the correct Step variant.
The version string used in @kernel_process_step_metadata must uniquely identify the Step class'
behavior and contract version. Different versions imply incompatible step behavior, state schema,
or function/event definitions.
Example usage:
@kernel_process_step_metadata("CutFoodStep.V2")
class CutFoodWithSharpeningStep(KernelProcessStep[MyState]):
...
"""
def decorator(cls):
setattr(cls, "_kernel_process_step_metadata", KernelProcessStepMetadataAttribute(version))
return cls
return decorator
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Generic, Literal, TypeVar
from pydantic import ConfigDict, Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
TState = TypeVar("TState")
@experimental
class KernelProcessStepState(KernelBaseModel, Generic[TState]):
"""The state of a step in a kernel process."""
type: Literal["KernelProcessStepState"] = Field(default="KernelProcessStepState") # type: ignore
name: str
version: str
id: str | None = None
state: TState | None = None
model_config = ConfigDict(extra="ignore")
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from pathlib import Path
from typing import ClassVar, Generic, Literal, TypeVar
from pydantic import Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
TState = TypeVar("TState")
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class KernelProcessStepStateMetadata(KernelBaseModel, Generic[TState]):
"""Process state used for State Persistence serialization."""
type_: Literal["Step", "Process"] = Field(
"Step", alias="$type"
) # satisfies mypy to have `Steps` and `Process` as type
id: str | None = Field(None, alias="id")
name: str | None = Field(None, alias="name")
version_info: str | None = Field(None, alias="versionInfo")
state: TState | None = Field(None, alias="state") # type: ignore[valid-type]
@experimental
class KernelProcessStateMetadata(KernelProcessStepStateMetadata[TState]):
"""Process state used for State Persistence serialization."""
type_: Literal["Process"] = Field("Process", alias="$type")
steps_state: dict[str, "KernelProcessStateMetadata | KernelProcessStepStateMetadata"] = Field(
default_factory=dict, alias="stepsState"
)
model_config: ClassVar = {
"populate_by_name": True,
}
@classmethod
def load_from_file(
cls,
json_filename: str,
directory: str | Path,
encoding: str = "utf-8",
) -> "KernelProcessStateMetadata | None":
"""Loads a KernelProcessStateMetadata instance from a JSON file.
Args:
json_filename (str): Name of the JSON file to load.
directory (str | Path): Base directory where the file resides.
Can be relative or absolute.
encoding (str, optional): Encoding to use when reading the file. Defaults to 'utf-8'.
Returns:
KernelProcessStateMetadata | None: Loaded process state metadata or None on failure.
"""
base_dir = Path(directory)
file_path = base_dir / json_filename
if not file_path.exists():
logger.warning(f"File not found: '{file_path.resolve()}'")
return None
try:
with open(file_path, encoding=encoding) as f:
file_contents = f.read()
return cls.model_validate_json(file_contents)
except Exception as ex:
logger.error(f"Error reading file '{file_path.resolve()}': {ex}")
return None
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_event import (
KernelProcessEvent,
KernelProcessEventVisibility,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class LocalEvent(KernelBaseModel):
"""An event that is local to a namespace."""
namespace: str | None
inner_event: KernelProcessEvent
@property
def id(self) -> str:
"""The ID of the event."""
return f"{self.namespace}.{self.inner_event.id}" if self.namespace else self.inner_event.id
@property
def data(self) -> Any:
"""The data of the event."""
return self.inner_event.data
@property
def visibility(self) -> KernelProcessEventVisibility:
"""The visibility of the event."""
return self.inner_event.visibility
@classmethod
def from_kernel_process_event(cls, kernel_process_event: KernelProcessEvent, namespace: str) -> "LocalEvent":
"""Create a local event from a kernel process event."""
return cls(namespace=namespace, inner_event=kernel_process_event)
@@ -0,0 +1,52 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from typing import TYPE_CHECKING
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
from semantic_kernel.processes.local_runtime.local_event import KernelProcessEvent
from semantic_kernel.processes.local_runtime.local_kernel_process_context import LocalKernelProcessContext
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.kernel import Kernel
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
@experimental
async def start(
process: "KernelProcess",
kernel: "Kernel",
initial_event: KernelProcessEvent | str | Enum,
max_supersteps: int | None = None,
**kwargs,
) -> LocalKernelProcessContext:
"""Start the kernel process.
Args:
process: The kernel process to start.
kernel: The kernel instance.
initial_event: The initial event to start the process with.
max_supersteps: The maximum number of supersteps. This is the total number of times process steps will run.
Defaults to None, and thus the process will run its steps 100 times.
**kwargs: Additional keyword arguments.
"""
if process is None:
raise ProcessInvalidConfigurationException("process cannot be None")
if process.state is None or not process.state.name:
raise ProcessInvalidConfigurationException("process state name cannot be empty")
if kernel is None:
raise ProcessInvalidConfigurationException("kernel cannot be None")
if initial_event is None:
raise ProcessInvalidConfigurationException("initial_event cannot be None")
initial_event_str: str | KernelProcessEvent = (
initial_event.value if isinstance(initial_event, Enum) else initial_event
)
if isinstance(initial_event_str, str):
initial_event_str = KernelProcessEvent(id=initial_event_str, data=kwargs.get("data"))
process_context = LocalKernelProcessContext(process, kernel, max_supersteps)
await process_context.start_with_event(initial_event_str)
return process_context
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING
from semantic_kernel.kernel import Kernel
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.local_runtime.local_process import LocalProcess
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
from semantic_kernel.processes.local_runtime.local_event import KernelProcessEvent
@experimental
class LocalKernelProcessContext(KernelBaseModel):
"""A local kernel process context."""
local_process: LocalProcess
def __init__(self, process: "KernelProcess", kernel: "Kernel", max_supersteps: int | None = None) -> None:
"""Initializes the local kernel process context.
Args:
process: The kernel process to start.
kernel: The kernel instance.
max_supersteps: The maximum number of supersteps. This is the total number of times process steps will run.
Defaults to None.
"""
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess # noqa: F401
LocalProcess.model_rebuild()
if not process or not process.state or not process.state.name.strip():
raise ValueError("Process and process state must be provided and have a valid name")
if not kernel:
raise ValueError("Kernel must be provided")
local_process = LocalProcess(
process=process,
kernel=kernel,
parent_process_id=None,
factories=process.factories,
max_supersteps=max_supersteps,
)
super().__init__(local_process=local_process) # type: ignore
async def start_with_event(self, initial_event: "KernelProcessEvent") -> None:
"""Starts the local process with an initial event."""
await self.local_process.run_once(initial_event)
async def send_event(self, process_event: "KernelProcessEvent") -> None:
"""Sends an event to the process."""
await self.local_process.send_message(process_event)
async def stop(self) -> None:
"""Stops the local process."""
await self.local_process.stop()
async def get_state(self) -> "KernelProcess":
"""Gets the current state of the process."""
return await self.local_process.get_process_info()
async def __aenter__(self):
"""Enters the async context (used for resource management)."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exits the async context and disposes of resources."""
await self.dispose()
async def dispose(self) -> None:
"""Disposes of the resources used by the process."""
if self.local_process:
self.local_process.dispose()
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from pydantic import Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class LocalMessage(KernelBaseModel):
"""A message that is local to a namespace."""
source_id: str = Field(...)
destination_id: str = Field(...)
function_name: str = Field(...)
values: dict[str, Any | None] = Field(...)
target_event_id: str | None = Field(default=None)
target_event_data: Any | None = Field(default=None)
@@ -0,0 +1,30 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.local_runtime.local_message import LocalMessage
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class LocalMessageFactory:
"""Factory class to create LocalMessage instances."""
@staticmethod
def create_from_edge(edge: "KernelProcessEdge", data: Any | None = None) -> "LocalMessage":
"""Creates a new LocalMessage instance from a KernelProcessEdge and a data object."""
target = edge.output_target
parameter_value: dict[str, Any | None] = {}
if target.parameter_name:
parameter_value[target.parameter_name] = data
return LocalMessage(
source_id=edge.source_step_id,
destination_id=target.step_id,
function_name=target.function_name,
values=parameter_value,
target_event_id=target.target_event_id,
target_event_data=data,
)
@@ -0,0 +1,265 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import contextlib
import logging
import uuid
from collections.abc import Callable
from queue import Queue
from typing import TYPE_CHECKING, Any
from pydantic import Field
from semantic_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.kernel_process.kernel_process_state import KernelProcessState
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.local_runtime.local_event import (
KernelProcessEvent,
KernelProcessEventVisibility,
LocalEvent,
)
from semantic_kernel.processes.local_runtime.local_message import LocalMessage
from semantic_kernel.processes.local_runtime.local_message_factory import LocalMessageFactory
from semantic_kernel.processes.local_runtime.local_step import LocalStep
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class LocalProcess(LocalStep):
"""A local process that contains a collection of steps."""
kernel: Kernel
steps: list[LocalStep] = Field(default_factory=list)
step_infos: list[KernelProcessStepInfo] = Field(default_factory=list)
process: "KernelProcess"
initialize_task: bool | None = False
external_event_queue: Queue = Field(default_factory=Queue)
process_task: asyncio.Task | None = None
factories: dict[str, Callable] = Field(default_factory=dict)
max_supersteps: int = Field(
default=100, ge=1, description="Maximum number of supersteps to execute before stopping the process."
)
def __init__(
self,
process: "KernelProcess",
kernel: Kernel,
factories: dict[str, Callable] | None = None,
parent_process_id: str | None = None,
max_supersteps: int | None = None,
):
"""Initializes the local process."""
args: dict[str, Any] = {
"step_info": process,
"kernel": kernel,
"parent_process_id": parent_process_id,
"step_infos": list(process.steps),
"process": process,
"initialize_task": False,
}
if max_supersteps is not None:
args["max_supersteps"] = max_supersteps
if factories:
args["factories"] = factories
super().__init__(**args)
def ensure_initialized(self):
"""Ensures the process is initialized lazily (synchronously)."""
if not self.initialize_task:
self.initialize_process()
self.initialize_task = True
async def start(self, keep_alive: bool = True) -> None:
"""Starts the process with an initial event.
Args:
keep_alive: Indicates if the process should wait for external events after it's finished processing.
"""
self.ensure_initialized()
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):
"""Starts the process with an initial event and waits for it to finish.
Args:
process_event: The KernelProcessEvent to start the process with.
"""
if process_event is None:
raise ProcessEventUndefinedException("The process event must be specified.")
self.external_event_queue.put(process_event)
await self.start(keep_alive=False)
if self.process_task:
await self.process_task
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):
"""Initializes the step."""
# The process does not need any further initialization
pass
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) -> "KernelProcess":
"""Gets the process information."""
return await self.to_kernel_process()
def initialize_process(self):
"""Initializes the input and output edges for the process and initializes the steps."""
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
# Initialize the input and output edges for the process
self.output_edges = {kvp[0]: list(kvp[1]) for kvp in self.process.edges.items()}
# Initialize the steps within this process
for step in self.step_infos:
local_step = None
# The current step should already have a name.
assert step.state and step.state.name is not None # nosec
if isinstance(step, KernelProcess):
# 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)
# Create a new LocalProcess for the step
process = LocalProcess(
process=step,
kernel=self.kernel,
factories=self.factories,
parent_process_id=self.id,
)
local_step = process
else:
# The current step should already have an Id.
assert step.state and step.state.id is not None # nosec
# Create a LocalStep for the step
local_step = LocalStep( # type: ignore
step_info=step,
kernel=self.kernel,
factories=self.factories,
parent_process_id=self.id,
)
# Add the local step to the list of steps
self.steps.append(local_step)
async def handle_message(self, message: LocalMessage):
"""Handles a LocalMessage that has been sent to the process."""
if not message.target_event_id:
error_message = (
"Internal Process Error: The target event id must be specified when sending a message to a step."
)
logger.error(error_message)
raise KernelException(error_message)
event_id = message.target_event_id
if event_id in self.output_edges:
nested_event = KernelProcessEvent(
id=event_id, data=message.target_event_data, visibility=KernelProcessEventVisibility.Internal
)
await self.run_once(nested_event)
async def internal_execute(self, max_supersteps: int = 100, keep_alive: bool = True):
"""Internal execution logic for the process."""
message_channel: Queue[LocalMessage] = Queue()
logger.debug(f"Running process for {max_supersteps} supersteps.")
try:
for _ in range(max_supersteps):
self.enqueue_external_messages(message_channel)
for step in self.steps:
await self.enqueue_step_messages(step, message_channel)
messages_to_process: list[LocalMessage] = []
while not message_channel.empty():
messages_to_process.append(message_channel.get())
if not messages_to_process and (not keep_alive or self.external_event_queue.empty()):
break
message_tasks = []
for message in messages_to_process:
if message.destination_id == END_PROCESS_ID:
break
destination_step = next(step for step in self.steps if step.id == message.destination_id)
message_tasks.append(destination_step.handle_message(message))
await asyncio.gather(*message_tasks)
except Exception as ex:
logger.error(f"An error occurred while running the process: {ex}.")
raise
async def to_kernel_process(self) -> "KernelProcess":
"""Builds a KernelProcess from the current LocalProcess."""
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
process_state = KernelProcessState(name=self.name, version=self.step_state.version, id=self.id)
step_tasks = [step.to_kernel_process_step_info() for step in self.steps]
steps = await asyncio.gather(*step_tasks)
return KernelProcess(state=process_state, steps=steps, edges=self.output_edges)
async def to_kernel_process_step_info(self) -> "KernelProcessStepInfo":
"""Extracts the current state of the step and returns it as a KernelProcessStepInfo."""
return await self.to_kernel_process()
def enqueue_external_messages(self, message_channel: Queue[LocalMessage]):
"""Processes external events that have been sent to the process."""
while not self.external_event_queue.empty():
external_event: KernelProcessEvent = self.external_event_queue.get_nowait()
if external_event.id in self.output_edges:
edges = self.output_edges[external_event.id]
for edge in edges:
message = LocalMessageFactory.create_from_edge(edge, external_event.data)
message_channel.put(message)
async def enqueue_step_messages(self, step: LocalStep, message_channel: Queue[LocalMessage]):
"""Processes events emitted by the given step and enqueues them."""
all_step_events = step.get_all_events()
for step_event in all_step_events:
# must come first because emitting the step event modifies its namespace
for edge in step.get_edge_for_event(step_event.id):
message = LocalMessageFactory.create_from_edge(edge, step_event.data)
message_channel.put(message)
if step_event.visibility == KernelProcessEventVisibility.Public:
if isinstance(step_event, KernelProcessEvent):
await self.emit_event(step_event) # type: ignore
elif isinstance(step_event, LocalEvent):
await self.emit_local_event(step_event) # type: ignore
def dispose(self):
"""Clean up resources."""
if self.process_task:
self.process_task.cancel()
@@ -0,0 +1,303 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import uuid
from collections.abc import Callable
from inspect import isawaitable
from queue import Queue
from typing import Any
from pydantic import Field, model_validator
from semantic_kernel import Kernel
from semantic_kernel.exceptions import KernelException
from semantic_kernel.exceptions.process_exceptions import (
ProcessFunctionNotFoundException,
ProcessTargetFunctionNameMismatchException,
)
from semantic_kernel.functions import KernelFunction
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
from semantic_kernel.processes.kernel_process.kernel_process_message_channel import KernelProcessMessageChannel
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.processes.local_runtime.local_event import LocalEvent
from semantic_kernel.processes.local_runtime.local_message import LocalMessage
from semantic_kernel.processes.process_types import get_generic_state_type
from semantic_kernel.processes.step_utils import find_input_channels, get_fully_qualified_name
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class LocalStep(KernelProcessMessageChannel, KernelBaseModel):
"""A local step that is part of a local process."""
kernel: "Kernel"
step_info: "KernelProcessStepInfo"
outgoing_event_queue: Queue[LocalEvent] = Field(default_factory=Queue)
initialize_task: bool | None = False
event_namespace: str
step_state: KernelProcessStepState
inputs: dict[str, dict[str, Any | None]] = Field(default_factory=dict)
initial_inputs: dict[str, dict[str, Any | None]] = Field(default_factory=dict)
functions: dict[str, KernelFunction] = Field(default_factory=dict)
output_edges: dict[str, list[KernelProcessEdge]] = Field(default_factory=dict)
parent_process_id: str | None = None
init_lock: asyncio.Lock = Field(default_factory=asyncio.Lock, exclude=True)
factories: dict[str, Callable]
@model_validator(mode="before")
@classmethod
def parse_initial_configuration(cls, data: Any) -> Any:
"""Parses the initial configuration of the step."""
if isinstance(data, dict):
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
step_info = data.get("step_info")
assert step_info is not None # nosec
if step_info and isinstance(step_info, KernelProcess) and step_info.state.id is None:
step_info.state.id = str(uuid.uuid4().hex)
data["step_state"] = step_info.state
data["output_edges"] = {k: v for k, v in step_info.edges.items()}
data["event_namespace"] = f"{step_info.state.name}_{step_info.state.id}"
return data
@property
def name(self) -> str:
"""Gets the name of the step."""
return self.step_info.state.name
@property
def id(self) -> str:
"""Gets the ID of the step."""
return self.step_info.state.id if self.step_info.state.id else ""
async def handle_message(self, message: LocalMessage):
"""Handles a LocalMessage that has been sent to the step."""
if message is None:
raise ValueError("The message is None.")
if not self.initialize_task:
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.initialize_task:
await self.initialize_step()
self.initialize_task = True
if self.functions is None or self.inputs is None or self.initial_inputs is None:
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
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."""
await self.emit_local_event(LocalEvent.from_kernel_process_event(process_event, self.event_namespace))
async def emit_local_event(self, local_event: "LocalEvent"):
"""Emits an event from the step."""
scoped_event = self.scoped_event(local_event)
self.outgoing_event_queue.put(scoped_event)
async def initialize_step(self):
"""Initializes the step."""
# Instantiate an instance of the inner step object
step_cls = self.step_info.inner_step_type
factory = (
self.factories.get(get_fully_qualified_name(self.step_info.inner_step_type)) if self.factories else None
)
if factory:
step_instance = factory()
if isawaitable(step_instance):
step_instance = await step_instance
step_cls = type(step_instance)
else:
step_instance = 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
t_state = get_generic_state_type(step_cls)
if t_state is not None:
# Create state_type as KernelProcessStepState[TState]
state_type = KernelProcessStepState[t_state]
if state_object is None:
state_object = state_type(
name=step_cls.__name__,
id=step_cls.__name__,
state=None,
)
else:
# Make sure state_object is an instance of state_type
if not isinstance(state_object, KernelProcessStepState):
error_message = "State object is not of the expected type."
raise KernelException(error_message)
# Make sure that state_object.state is not None
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:
# 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)
def get_all_events(self) -> list["LocalEvent"]:
"""Retrieves all events that have been emitted by this step in the previous superstep."""
all_events = []
while not self.outgoing_event_queue.empty():
all_events.append(self.outgoing_event_queue.get())
return all_events
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, [])
async def to_kernel_process_step_info(self) -> "KernelProcessStepInfo":
"""Extracts the current state of the step and returns it as a KernelProcessStepInfo."""
if not self.initialize_task:
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.initialize_task:
await self.initialize_step()
self.initialize_task = True
return KernelProcessStepInfo(
inner_step_type=self.step_info.inner_step_type, state=self.step_state, output_edges=self.output_edges
)
def scoped_event(self, local_event: "LocalEvent") -> "LocalEvent":
"""Generates a scoped event for the step."""
if local_event is None:
raise ValueError("The local event must be specified.")
local_event.namespace = f"{self.name}_{self.id}"
return local_event
def scoped_event_from_kernel_process(self, process_event: "KernelProcessEvent") -> "LocalEvent":
"""Generates a scoped event for the step from a KernelProcessEvent."""
if process_event is None:
raise ValueError("The process event must be specified.")
return LocalEvent.from_kernel_process_event(process_event, f"{self.name}_{self.id}")
@@ -0,0 +1,254 @@
# Copyright (c) Microsoft. All rights reserved.
import contextlib
import inspect
from collections.abc import Callable
from copy import copy
from enum import Enum
from typing import TYPE_CHECKING, cast
from pydantic import Field
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
from semantic_kernel.processes.kernel_process.kernel_process_function_target import KernelProcessFunctionTarget
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.kernel_process.kernel_process_step_state_metadata import (
KernelProcessStateMetadata,
KernelProcessStepStateMetadata,
)
from semantic_kernel.processes.process_edge_builder import ProcessEdgeBuilder
from semantic_kernel.processes.process_function_target_builder import ProcessFunctionTargetBuilder
from semantic_kernel.processes.process_state_metadata_utils import to_process_state_metadata
from semantic_kernel.processes.process_step_builder import ProcessStepBuilder
from semantic_kernel.processes.process_step_edge_builder import ProcessStepEdgeBuilder
from semantic_kernel.processes.process_types import TState, TStep
from semantic_kernel.processes.step_utils import get_fully_qualified_name
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
@experimental
class ProcessBuilder(ProcessStepBuilder):
"""A builder for a process."""
entry_steps: list["ProcessStepBuilder"] = Field(default_factory=list)
external_event_target_map: dict[str, "ProcessFunctionTargetBuilder"] = Field(default_factory=dict)
has_parent_process: bool = False
version: str = "v1"
steps: list["ProcessStepBuilder"] = Field(default_factory=list)
factories: dict[str, Callable] = Field(default_factory=dict)
def add_step(
self,
step_type: type[TStep],
name: str | None = None,
initial_state: TState | None = None,
factory_function: Callable | None = None,
aliases: list[str] | None = None,
**kwargs,
) -> ProcessStepBuilder[TState, TStep]:
"""Register a step type with optional constructor arguments.
Args:
step_type: The step type.
name: The name of the step. Defaults to None.
initial_state: The initial state of the step. Defaults to None.
factory_function: The factory function. Allows for a callable that is used to create the step instance
that may have complex dependencies that cannot be JSON serialized or deserialized. Defaults to None.
aliases: The aliases of the step. Defaults to None.
kwargs: Additional keyword arguments.
Returns:
The process step builder.
"""
if not inspect.isclass(step_type):
raise ProcessInvalidConfigurationException(
f"Expected a class type, but got an instance of {type(step_type).__name__}"
)
if factory_function:
fq_name = get_fully_qualified_name(step_type)
self.factories[fq_name] = factory_function
name = name or step_type.__name__
process_step_builder = ProcessStepBuilder(
type=step_type, name=name, initial_state=initial_state, aliases=aliases, **kwargs
)
self.steps.append(process_step_builder)
return process_step_builder
def add_step_from_process(
self, kernel_process: "ProcessBuilder", aliases: list[str] | None = None
) -> "ProcessBuilder":
"""Adds a step from the given process.
Args:
kernel_process: The process to add.
aliases: The aliases of the step. Defaults to None.
Returns:
The process builder.
"""
kernel_process.has_parent_process = True
if aliases:
kernel_process.aliases = aliases
self.steps.append(kernel_process)
return kernel_process
def resolve_function_target(
self, function_name: str | None, parameter_name: str | None
) -> KernelProcessFunctionTarget:
"""Resolves the function target."""
targets = []
for step in self.entry_steps:
with contextlib.suppress(ValueError):
targets.append(step.resolve_function_target(function_name, parameter_name))
if len(targets) == 0:
raise ValueError(f"No targets found for function '{function_name}.{parameter_name}'")
if len(targets) > 1:
raise ValueError(f"Multiple targets found for function '{function_name}.{parameter_name}'")
return targets[0]
def where_input_event_is(self, event_id: str | Enum) -> "ProcessFunctionTargetBuilder":
"""Filters the input event."""
event_id_str: str = event_id.value if isinstance(event_id, Enum) else event_id
if event_id_str not in self.external_event_target_map:
raise ValueError(f"The process named '{self.name}' does not expose an event with Id '{event_id_str}'")
target = self.external_event_target_map[event_id_str]
target = copy(target)
target.step = self
target.target_event_id = event_id_str
return target
def on_input_event(self, event_id: str | Enum) -> "ProcessEdgeBuilder": # type: ignore
"""Creates a new ProcessEdgeBuilder for the input event."""
from semantic_kernel.processes.process_builder import ProcessBuilder # noqa: F401
ProcessEdgeBuilder.model_rebuild()
event_id_str: str = event_id.value if isinstance(event_id, Enum) else event_id
return ProcessEdgeBuilder(source=self, event_id=event_id_str)
def link_to(self, event_id: str, edge_builder: ProcessStepEdgeBuilder) -> None:
"""Links to the given event ID."""
if edge_builder.target is None:
raise ValueError("Target must be set before linking")
self.entry_steps.append(edge_builder.source)
self.external_event_target_map[event_id] = edge_builder.target
super().link_to(event_id, edge_builder)
def build_step(self, state_metadata: KernelProcessStepStateMetadata | None = None) -> KernelProcessStepInfo:
"""Builds the process step."""
# The process is a step so we can return the step info directly
# convert to KernelProcessStateMetadata...
if state_metadata is None or isinstance(state_metadata, KernelProcessStateMetadata):
metadata: KernelProcessStateMetadata | None = cast(KernelProcessStateMetadata | None, state_metadata)
else:
metadata = KernelProcessStateMetadata(
name=self.name,
id=self.id if self.has_parent_process else None,
version_info=self.version,
)
return self.build(state_metadata=metadata)
def build(self, state_metadata: KernelProcessStateMetadata | None = None) -> "KernelProcess":
"""Builds the KernelProcess."""
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
built_edges = {key: [edge.build() for edge in edges] for key, edges in self.edges.items()}
built_steps = [step.build_step() for step in self.steps]
built_steps = self._build_with_state_metadata(state_metadata=state_metadata)
process_state = KernelProcessState(
name=self.name, id=self.id if self.has_parent_process else None, version=self.version
)
return KernelProcess(state=process_state, steps=built_steps, edges=built_edges, factories=self.factories)
def _build_with_state_metadata(
self, state_metadata: "KernelProcessStateMetadata | None"
) -> list["KernelProcessStepInfo"]:
built_steps: list["KernelProcessStepInfo"] = []
# 1- Validate StateMetadata: Migrate previous state versions if needed + sanitize state
sanitized_metadata: "KernelProcessStateMetadata | None" = None
if state_metadata is not None:
sanitized_metadata = self._sanitize_process_state_metadata(state_metadata, self.steps)
# 2- Build steps info with validated stateMetadata
for step in self.steps:
if (
sanitized_metadata
and sanitized_metadata.steps_state
and step.name in sanitized_metadata.steps_state
and sanitized_metadata.steps_state[step.name] is not None
):
built_steps.append(step.build_step(sanitized_metadata.steps_state[step.name]))
else:
built_steps.append(step.build_step())
return built_steps
def _sanitize_process_state_metadata(
self, state_metadata: "KernelProcessStateMetadata", step_builders: list["ProcessStepBuilder"]
) -> "KernelProcessStateMetadata":
sanitized_state_metadata = state_metadata
for step in step_builders:
# 1- find matching key name with exact match or by alias match
step_key: str | None = None
if sanitized_state_metadata.steps_state and step.name in sanitized_state_metadata.steps_state:
step_key = step.name
else:
step_key = next(
(
alias
for alias in step.aliases
if sanitized_state_metadata.steps_state and alias in sanitized_state_metadata.steps_state
),
None,
)
# 2- stepKey match found
if step_key is not None:
current_version_state_metadata = to_process_state_metadata(step.build_step())
saved_state_metadata = sanitized_state_metadata.steps_state.get(step_key)
if saved_state_metadata is not None and step_key != step.name:
if saved_state_metadata.version_info == current_version_state_metadata.version_info:
# key mismatch only, but same version
sanitized_state_metadata.steps_state[step.name] = saved_state_metadata
else:
# version mismatch - check if migration logic in place
if isinstance(step, ProcessBuilder):
if isinstance(saved_state_metadata, KernelProcessStepStateMetadata):
saved_state_metadata = KernelProcessStateMetadata(
name=step.name,
id=step.id,
version_info=step.version,
steps_state={},
)
sanitized_step_state = self._sanitize_process_state_metadata(
saved_state_metadata, step.steps
)
sanitized_state_metadata.steps_state[step.name] = sanitized_step_state
else:
# no compatible state found, migrating id only
sanitized_state_metadata.steps_state[step.name] = type(saved_state_metadata)(
Name=step.name,
Id=step.id,
)
sanitized_state_metadata.steps_state[step.name].name = step.name
del sanitized_state_metadata.steps_state[step_key]
return sanitized_state_metadata
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.process_function_target_builder import ProcessFunctionTargetBuilder
from semantic_kernel.processes.process_step_builder import ProcessStepBuilder
from semantic_kernel.processes.process_step_edge_builder import ProcessStepEdgeBuilder
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.process_builder import ProcessBuilder
@experimental
class ProcessEdgeBuilder(KernelBaseModel):
"""A builder for a process edge."""
source: "ProcessBuilder"
target: ProcessFunctionTargetBuilder | None = None
event_id: str
def __init__(self, source: "ProcessBuilder", event_id: str):
"""Initializes a new instance of ProcessEdgeBuilder."""
super().__init__(source=source, event_id=event_id) # type: ignore
def send_event_to(
self, target: ProcessFunctionTargetBuilder | ProcessStepBuilder, **kwargs
) -> "ProcessEdgeBuilder":
"""Sends the event to the target."""
if target is None:
raise TypeError("Target cannot be None")
if isinstance(target, ProcessStepBuilder):
target = ProcessFunctionTargetBuilder(
step=target, parameter_name=kwargs.get("parameter_name"), function_name=kwargs.get("function_name")
)
self.target = target
edge_builder = ProcessStepEdgeBuilder(source=self.source, event_id=self.event_id)
edge_builder.target = self.target
self.source.link_to(self.event_id, edge_builder)
return ProcessEdgeBuilder(source=self.source, event_id=self.event_id)
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from typing import TYPE_CHECKING, ClassVar
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.processes.kernel_process.kernel_process_step_state_metadata import KernelProcessStepStateMetadata
from semantic_kernel.processes.process_step_builder import ProcessStepBuilder
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if TYPE_CHECKING:
from semantic_kernel.functions import KernelFunctionMetadata
from semantic_kernel.kernel import Kernel
@experimental
class EndStep(ProcessStepBuilder):
"""An end step in a process."""
END_STEP_VALUE: ClassVar[str] = "Microsoft.SemanticKernel.Process.EndStep"
END_STEP_NAME: ClassVar[str] = END_STEP_VALUE
END_STEP_ID: ClassVar[str] = END_STEP_VALUE
END_STEP_VERSION: ClassVar[str] = "v0"
@staticmethod
def get_instance() -> "EndStep":
"""Get the instance of the end step."""
return EndStep()
def __init__(self):
"""Initialize the end step."""
super().__init__(
name=self.END_STEP_VALUE,
)
def get_function_metadata_map(
self, plugin_type, name: str | None = None, kernel: "Kernel | None" = None
) -> dict[str, "KernelFunctionMetadata"]:
"""Gets the function metadata map."""
return {}
@override
def build_step(self, state_metadata: KernelProcessStepStateMetadata | None = None) -> KernelProcessStepInfo:
"""Build the step."""
return KernelProcessStepInfo(
inner_step_type=KernelProcessStepState,
state=KernelProcessStepState(name=self.END_STEP_NAME, version=self.END_STEP_VERSION),
output_edges={},
)
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_event import (
KernelProcessEvent,
KernelProcessEventVisibility,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ProcessEvent(KernelBaseModel):
"""A wrapper around KernelProcessEvent that helps to manage the namespace of the event."""
namespace: str | None = None
inner_event: KernelProcessEvent
@property
def id(self) -> str:
"""The Id of the event."""
return f"{self.namespace}.{self.inner_event.id}"
@property
def data(self) -> Any | None:
"""The data of the event."""
return self.inner_event.data
@property
def visibility(self) -> "KernelProcessEventVisibility":
"""The visibility of the event."""
return self.inner_event.visibility
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_function_target import KernelProcessFunctionTarget
from semantic_kernel.processes.process_end_step import EndStep
from semantic_kernel.processes.process_step_builder import ProcessStepBuilder
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ProcessFunctionTargetBuilder(KernelBaseModel):
"""A builder for a process function target."""
step: ProcessStepBuilder
function_name: str | None = None
parameter_name: str | None = None
target_event_id: str | None = None
def __init__(
self, step: ProcessStepBuilder, function_name: str | Enum | None = None, parameter_name: str | None = None
):
"""Initializes a new instance of ProcessFunctionTargetBuilder."""
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata # noqa: F401
ProcessFunctionTargetBuilder.model_rebuild()
function_name_str: str | None = function_name.value if isinstance(function_name, Enum) else function_name
if isinstance(step, EndStep):
function_name_str = "END"
parameter_name = None
else:
target = step.resolve_function_target(function_name_str, parameter_name)
function_name_str = target.function_name
parameter_name = target.parameter_name
super().__init__(step=step, function_name=function_name_str, parameter_name=parameter_name)
def build(self) -> KernelProcessFunctionTarget:
"""Builds the KernelProcessFunctionTarget."""
if not self.step.id:
raise ValueError("Step ID cannot be None")
return KernelProcessFunctionTarget(
step_id=self.step.id,
function_name=self.function_name,
parameter_name=self.parameter_name,
target_event_id=self.target_event_id,
)
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ProcessMessage(KernelBaseModel):
"""Represents a message used in a process runtime."""
source_id: str
destination_id: str
function_name: str
values: dict[str, Any]
target_event_id: str | None = None
target_event_data: Any | None = None
@@ -0,0 +1,29 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.process_message import ProcessMessage
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ProcessMessageFactory:
"""Factory class for creating ProcessMessage instances."""
@staticmethod
def create_from_edge(edge: KernelProcessEdge, data: Any) -> ProcessMessage:
"""Creates a new ProcessMessage from a KernelProcessEdge."""
target = edge.output_target
parameter_value: dict[str, Any] = {}
if target.parameter_name is not None:
parameter_value[target.parameter_name] = data
return ProcessMessage(
source_id=edge.source_step_id,
destination_id=target.step_id,
function_name=target.function_name,
values=parameter_value,
target_event_id=target.target_event_id,
target_event_data=data,
)
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING, Any
from semantic_kernel.processes.kernel_process.kernel_process_step_metadata import KernelProcessStepMetadataAttribute
from semantic_kernel.processes.kernel_process.kernel_process_step_state_metadata import (
KernelProcessStateMetadata,
KernelProcessStepStateMetadata,
)
from semantic_kernel.processes.process_types import get_generic_state_type
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.kernel_process import KernelProcess
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
@experimental
def extract_process_step_metadata_from_type(step_cls: type) -> KernelProcessStepMetadataAttribute:
"""Extracts the process step metadata from the type."""
return getattr(step_cls, "_kernel_process_step_metadata", KernelProcessStepMetadataAttribute("v1"))
@experimental
def kernel_process_to_process_state_metadata(kernel_process: "KernelProcess") -> KernelProcessStateMetadata:
"""Converts a kernel process to process state metadata."""
KernelProcessStateMetadata.model_rebuild()
metadata: KernelProcessStateMetadata[Any] = KernelProcessStateMetadata(
name=kernel_process.state.name,
id=kernel_process.state.id,
version_info=kernel_process.state.version,
steps_state={},
)
for step in kernel_process.steps:
metadata.steps_state[step.state.name] = to_process_state_metadata(step)
return metadata
@experimental
def to_process_state_metadata(step_info: "KernelProcessStepInfo") -> KernelProcessStepStateMetadata:
"""Converts a step info object to process state metadata."""
from semantic_kernel.processes.kernel_process import KernelProcess
if isinstance(step_info, KernelProcess):
return kernel_process_to_process_state_metadata(step_info)
return step_info_to_process_state_metadata(step_info)
@experimental
def step_info_to_process_state_metadata(step_info: "KernelProcessStepInfo") -> KernelProcessStepStateMetadata:
"""Converts a step info object to process state metadata."""
metadata: KernelProcessStepStateMetadata[Any] = KernelProcessStepStateMetadata(
name=step_info.state.name,
id=step_info.state.id,
version_info=step_info.state.version,
)
generic_state_type = get_generic_state_type(step_info.inner_step_type)
if generic_state_type:
inner_state = getattr(step_info.state, "state", None)
if inner_state is not None:
metadata.state = inner_state
return metadata
@@ -0,0 +1,264 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import uuid
from enum import Enum
from typing import TYPE_CHECKING, Any, Generic
from pydantic import Field
from semantic_kernel.exceptions.kernel_exceptions import KernelException
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.processes.kernel_process.kernel_process_function_target import KernelProcessFunctionTarget
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
from semantic_kernel.processes.kernel_process.kernel_process_step_state_metadata import KernelProcessStepStateMetadata
from semantic_kernel.processes.process_state_metadata_utils import extract_process_step_metadata_from_type
from semantic_kernel.processes.process_types import TState, TStep, get_generic_state_type
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.functions import KernelFunctionMetadata
from semantic_kernel.kernel import Kernel
from semantic_kernel.processes.process_step_edge_builder import ProcessStepEdgeBuilder
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class ProcessStepBuilder(KernelBaseModel, Generic[TState, TStep]):
"""A builder for a process step."""
id: str | None = None
name: str
edges: dict[str, list[Any]] = Field(default_factory=dict)
functions_dict: dict[str, "KernelFunctionMetadata"] = Field(default_factory=dict)
event_namespace: str
function_type: type[TStep] | None = None
initial_state: TState | None = None
aliases: list[str] = Field(default_factory=list)
def __init__(
self,
name: str,
type: type[TStep] | None = None,
initial_state: TState | None = None,
aliases: list[str] | None = None,
**kwargs,
):
"""Initialize the ProcessStepBuilder with a step class type and name."""
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata # noqa: F401
if not name or not name.strip():
raise ValueError("Name cannot be null or empty")
id = uuid.uuid4().hex
event_namespace = f"{name}_{id}"
functions_dict = {}
if type:
functions_dict = self.get_function_metadata_map(type, name, kwargs.get("kernel"))
super().__init__(
name=name,
function_type=type,
event_namespace=event_namespace,
id=id,
functions_dict=functions_dict,
initial_state=initial_state,
aliases=aliases or [],
**kwargs,
)
def on_input_event(self, event_id: str | Enum) -> "ProcessStepEdgeBuilder":
"""Creates a new ProcessStepEdgeBuilder for the input event."""
from semantic_kernel.processes.process_step_edge_builder import ProcessStepEdgeBuilder
event_id_str: str = event_id.value if isinstance(event_id, Enum) else event_id
return ProcessStepEdgeBuilder(source=self, event_id=event_id_str)
def on_event(self, event_id: str | Enum) -> "ProcessStepEdgeBuilder":
"""Creates a new ProcessStepEdgeBuilder for the event."""
from semantic_kernel.processes.process_step_edge_builder import ProcessStepEdgeBuilder
event_id_str: str = event_id.value if isinstance(event_id, Enum) else event_id
scoped_event_id = self.get_scoped_event_id(event_id_str)
return ProcessStepEdgeBuilder(source=self, event_id=scoped_event_id)
def resolve_function_target(
self, function_name: str | None, parameter_name: str | None
) -> KernelProcessFunctionTarget:
"""Resolves the function target for the given function name and parameter name."""
verified_function_name = function_name
verified_parameter_name = parameter_name
if not self.functions_dict:
raise KernelException(f"The target step {self.name} has no functions.")
# Handle null or whitespace function name
if not verified_function_name or verified_function_name.strip() == "":
if len(self.functions_dict) > 1:
raise KernelException(
"The target step has more than one function, so a function name must be provided."
)
# Only one function is available; use its name
verified_function_name = next(iter(self.functions_dict.keys()))
# Verify that the target function exists
if verified_function_name not in self.functions_dict:
raise KernelException(f"The function {verified_function_name} does not exist on step {self.name}")
# Get function parameters using inspect
kernel_function_metadata = self.functions_dict[verified_function_name]
if verified_parameter_name is None:
# Exclude parameters of type KernelProcessStepContext
undetermined_parameters = [
p for p in kernel_function_metadata.parameters if p.type_ != "KernelProcessStepContext"
]
if len(undetermined_parameters) > 1:
raise KernelException(
f"The function {verified_function_name} on step {self.name} has more than one parameter, "
"so a parameter name must be provided."
)
# We can infer the parameter name from the function metadata
if len(undetermined_parameters) == 1:
parameter_name = undetermined_parameters[0].name
verified_parameter_name = parameter_name
return KernelProcessFunctionTarget(
step_id=self.id, function_name=verified_function_name, parameter_name=verified_parameter_name
)
def get_scoped_event_id(self, event_id: str) -> str:
"""Returns the scoped event ID."""
return f"{self.event_namespace}.{event_id}"
def get_subtype_of_stateful_step(self, type_to_check):
"""Check if the provided type is a subclass of a generic KernelProcessStep and return its generic type if so."""
while type_to_check is not None and type_to_check is not object:
if hasattr(type_to_check, "__orig_bases__"):
for base in type_to_check.__orig_bases__:
if hasattr(base, "__origin__") and base.__origin__ == KernelProcessStep:
return base # Return the generic type itself
type_to_check = type_to_check.__base__
return None
def build_step(self, state_metadata: KernelProcessStepStateMetadata | None = None) -> KernelProcessStepInfo:
"""Builds the process step."""
step_cls = self.function_type
if step_cls is None:
raise ProcessInvalidConfigurationException("function_type is not set.")
step_metadata_attr = extract_process_step_metadata_from_type(step_cls)
version = step_metadata_attr.version
t_state = get_generic_state_type(step_cls)
state_object: KernelProcessStepState | None = None
if t_state is not None:
# The step is a KernelProcessStep with user-defined state
parsed_state_from_metadata = None
if state_metadata and state_metadata.state is not None:
candidate = state_metadata.state
if isinstance(candidate, dict):
try:
parsed_state_from_metadata = (
t_state(**candidate) if hasattr(t_state, "__fields__") else t_state()
)
except Exception as ex:
raise KernelException(
f"The initial state provided for step {self.name} is not valid "
f" JSON or cannot be parsed: {ex}"
)
else:
if isinstance(candidate, t_state):
parsed_state_from_metadata = candidate
else:
raise KernelException(
f"The initial state for step {self.name} is not the "
f"correct type. Expected {t_state.__name__}."
)
actual_state = parsed_state_from_metadata if parsed_state_from_metadata is not None else self.initial_state
if actual_state is not None and not isinstance(actual_state, t_state):
raise ProcessInvalidConfigurationException(
f"The initial state provided for step {self.name} is not of the correct type. "
f"The expected type is {t_state.__name__}."
)
if actual_state is None:
actual_state = t_state() if hasattr(t_state, "__fields__") else t_state
state_object = KernelProcessStepState[t_state]( # type: ignore
name=self.name, id=self.id, version=version, state=actual_state
)
else:
# The step is a KernelProcessStep with no user-defined state
if self.initial_state is not None:
raise ProcessInvalidConfigurationException(
f"An initial state was provided for step {self.name}, but the step does not accept a state."
)
if state_metadata and state_metadata.state is not None:
state_object = KernelProcessStepState(
name=self.name, id=self.id, version=version, state=state_metadata.state
)
else:
state_object = KernelProcessStepState(name=self.name, id=self.id, version=version, state=None)
built_edges = {event_id: [edge.build() for edge in edges_list] for event_id, edges_list in self.edges.items()}
return KernelProcessStepInfo(inner_step_type=step_cls, state=state_object, output_edges=built_edges)
def on_function_result(self, function_name: str | Enum) -> "ProcessStepEdgeBuilder":
"""Creates a new ProcessStepEdgeBuilder for the function result.
Args:
function_name: The function name as a string or Enum.
Returns:
ProcessStepEdgeBuilder: The ProcessStepEdgeBuilder instance.
"""
function_name_str: str = function_name.value if isinstance(function_name, Enum) else function_name
return self.on_event(f"{function_name_str}.OnResult")
def get_function_metadata_map(
self, plugin_type, name: str | None = None, kernel: "Kernel | None" = None
) -> dict[str, "KernelFunctionMetadata"]:
"""Returns a mapping of function names to their metadata."""
from semantic_kernel.kernel import Kernel
if not kernel:
kernel = Kernel()
step_instance = plugin_type()
functions_dict = {}
plugin_name = name or self.__class__.__name__
plugin_functions = kernel.add_plugin(step_instance, plugin_name)
for func_name, func in plugin_functions.functions.items():
functions_dict[func_name] = func.metadata
return functions_dict
def link_to(self, event_id: str, edge_builder: "ProcessStepEdgeBuilder") -> None:
"""Links an event ID to a ProcessStepEdgeBuilder."""
edges = self.edges.get(event_id)
if edges is None:
edges = []
self.edges[event_id] = edges
# Add the edge builder to the list
edges.append(edge_builder)
@@ -0,0 +1,73 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
from semantic_kernel.processes.kernel_process.kernel_process_edge import KernelProcessEdge
from semantic_kernel.processes.process_end_step import EndStep
from semantic_kernel.processes.process_function_target_builder import ProcessFunctionTargetBuilder
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.processes.process_step_builder import ProcessStepBuilder
@experimental
class ProcessStepEdgeBuilder:
"""A builder for a process step edge."""
source: "ProcessStepBuilder"
target: ProcessFunctionTargetBuilder | None = None
event_id: str
def __init__(self, source: "ProcessStepBuilder", event_id: str):
"""Initializes a new instance of ProcessStepEdgeBuilder."""
self.source = source
self.event_id = event_id
def send_event_to(
self, target: "ProcessFunctionTargetBuilder | ProcessStepBuilder", **kwargs
) -> "ProcessStepEdgeBuilder":
"""Sends the event to the target.
Args:
target: The target to send the event to.
**kwargs: Additional keyword arguments.
Returns:
ProcessStepEdgeBuilder: The ProcessStepEdgeBuilder instance.
"""
from semantic_kernel.processes.process_step_builder import ProcessStepBuilder
if self.target is not None:
raise ProcessInvalidConfigurationException(
"An output target has already been set part of the ProcessStepEdgeBuilder."
)
if isinstance(target, ProcessStepBuilder):
target = ProcessFunctionTargetBuilder(
step=target, parameter_name=kwargs.get("parameter_name"), function_name=kwargs.get("function_name")
)
self.target = target
self.source.link_to(self.event_id, self)
return ProcessStepEdgeBuilder(source=self.source, event_id=self.event_id)
def stop_process(self):
"""Stops the process."""
if self.target is not None:
raise ProcessInvalidConfigurationException(
"An output target has already been set part of the ProcessStepEdgeBuilder."
)
output_target = ProcessFunctionTargetBuilder(EndStep.get_instance())
self.target = output_target
self.source.link_to("END", self)
def build(self) -> KernelProcessEdge:
"""Builds the KernelProcessEdge."""
if not self.source.name:
raise ValueError("Source step must have a valid name")
if self.target is None:
raise ValueError("Target must be provided")
return KernelProcessEdge(source_step_id=self.source.id, output_target=self.target.build())
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
import types
from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
from semantic_kernel.utils.feature_stage_decorator import experimental
TStep = TypeVar("TStep", bound=KernelProcessStep)
TState = TypeVar("TState")
@experimental
def get_generic_state_type(cls) -> Any:
"""Given a subclass of KernelProcessStep, retrieve the concrete type of 'state'."""
try:
type_hints = get_type_hints(cls)
t_state = type_hints.get("state", None)
if t_state is not None:
# Handle Union types and Python 3.12 '|' syntax explicitly
origin = get_origin(t_state)
if origin in {Union, types.UnionType}:
args = get_args(t_state)
# Filter out 'NoneType' and keep the remaining types
non_none_args = [arg for arg in args if arg is not type(None)]
t_state = non_none_args[0] if non_none_args else None
# Check if t_state is a TypeVar (e.g., TypeVar('TState'))
if isinstance(t_state, TypeVar):
t_state = None # We don't have a concrete type
return t_state
except Exception:
pass # nosec
# Recursively check base classes
for base in cls.__bases__:
t_state = get_generic_state_type(base)
if t_state is not None:
return t_state
return None
@@ -0,0 +1,129 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
import inspect
from collections.abc import Sequence
from typing import Any
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
from semantic_kernel.functions.kernel_function import KernelFunction
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_context import KernelProcessStepContext
from semantic_kernel.utils.feature_stage_decorator import experimental
DEFAULT_ALLOWED_MODULE_PREFIXES: tuple[str, ...] = ("semantic_kernel.",)
@experimental
def find_input_channels(
channel: KernelProcessMessageChannel, functions: dict[str, KernelFunction]
) -> dict[str, dict[str, Any | None]]:
"""Finds and creates input channels."""
if not functions:
raise ValueError("The step has not been initialized.")
inputs: dict[str, Any] = {}
for name, function in functions.items():
inputs[name] = {}
for param in function.metadata.parameters:
# Check for Kernel, and skip if necessary, since it is populated later on
if param.type_ == "Kernel":
continue
if not param.is_required:
continue
if param.type_ == "KernelProcessStepContext":
inputs[name][param.name] = KernelProcessStepContext(channel)
else:
inputs[name][param.name] = None
return inputs
@experimental
def get_fully_qualified_name(cls) -> str:
"""Gets the fully qualified name of a class."""
return f"{cls.__module__}.{cls.__name__}"
@experimental
def get_step_class_from_qualified_name(
full_class_name: str,
allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES,
) -> type[KernelProcessStep]:
"""Loads and validates a KernelProcessStep class from a fully qualified name.
This function validates that the loaded class is a proper subclass of
KernelProcessStep, preventing instantiation of arbitrary classes.
Args:
full_class_name: The fully qualified class name in Python import notation
(e.g., 'mypackage.mymodule.MyStep'). The module must be importable
from the current Python environment.
allowed_module_prefixes: Sequence of module prefixes that are allowed
to be imported. The module must start with one of these prefixes.
This check is performed BEFORE import to prevent execution of
module-level code in unauthorized modules. Defaults to
("semantic_kernel.",). Pass None to allow any module (not
recommended for production). An empty sequence blocks all modules.
Returns:
The validated class type that is a subclass of KernelProcessStep
Raises:
ProcessInvalidConfigurationException: Raised when:
- The class name format is invalid (missing module separator)
- The module is not in the allowed prefixes list (if provided)
- The module cannot be imported
- The class attribute doesn't exist in the module
- The attribute is not a class type
- The class is not a subclass of KernelProcessStep
"""
if not full_class_name or "." not in full_class_name:
raise ProcessInvalidConfigurationException(
f"Invalid step class name format: '{full_class_name}'. "
"Expected a fully qualified name like 'module.ClassName'."
)
module_name, class_name = full_class_name.rsplit(".", 1)
if not module_name or not class_name:
raise ProcessInvalidConfigurationException(
f"Invalid step class name format: '{full_class_name}'. Module name and class name cannot be empty."
)
# Check module allowlist BEFORE import to prevent module-level code execution
if allowed_module_prefixes is not None and not any(
module_name.startswith(prefix)
if prefix.endswith(".")
else (module_name == prefix or module_name.startswith(prefix + "."))
for prefix in allowed_module_prefixes
):
raise ProcessInvalidConfigurationException(
f"Module '{module_name}' is not in the allowed module prefixes: {allowed_module_prefixes}. "
f"Step class '{full_class_name}' cannot be loaded."
)
try:
module = importlib.import_module(module_name)
except ImportError as e:
raise ProcessInvalidConfigurationException(
f"Unable to import module '{module_name}' for step class '{full_class_name}': {e}"
) from e
try:
cls = getattr(module, class_name)
except AttributeError as e:
raise ProcessInvalidConfigurationException(
f"Class '{class_name}' not found in module '{module_name}': {e}"
) from e
if not inspect.isclass(cls):
raise ProcessInvalidConfigurationException(f"'{full_class_name}' is not a class type, got {type(cls).__name__}")
if not issubclass(cls, KernelProcessStep):
raise ProcessInvalidConfigurationException(
f"Step class '{full_class_name}' must be a subclass of KernelProcessStep. Got: {cls.__bases__}"
)
return cls