chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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`.
|
||||
"""
|
||||
...
|
||||
+35
@@ -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
|
||||
Reference in New Issue
Block a user