chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
# Semantic Kernel Processes in Dapr
|
||||
|
||||
This demo contains a FastAPI app that uses Dapr to run a Semantic Kernel Process. Dapr is a portable, event-driven runtime that can simplify the process of building resilient, stateful application that run in the cloud and/or edge. Dapr is a natural fit for hosting Semantic Kernel Processes and allows you to scale your processes in size and quantity without sacrificing performance, or reliability.
|
||||
|
||||
For more information about Semantic Kernel Processes and Dapr, see the following documentation:
|
||||
|
||||
#### Semantic Kernel Processes
|
||||
|
||||
- [Overview of the Process Framework (docs)](https://learn.microsoft.com/semantic-kernel/frameworks/process/process-framework)
|
||||
- [Getting Started with Processes (samples)](../../getting_started_with_processes/)
|
||||
- [Semantic Kernel Dapr Runtime](../../../semantic_kernel/processes/dapr_runtime/)
|
||||
|
||||
#### Dapr
|
||||
|
||||
- [Dapr documentation](https://docs.dapr.io/)
|
||||
- [Dapr Actor documentation](https://v1-10.docs.dapr.io/developing-applications/building-blocks/actors/)
|
||||
- [Dapr local development](https://docs.dapr.io/getting-started/install-dapr-selfhost/)
|
||||
|
||||
### Supported Dapr Extensions:
|
||||
|
||||
| Extension | Supported |
|
||||
|--------------------|:----:|
|
||||
| FastAPI | ✅ |
|
||||
| Flask | ✅ |
|
||||
| gRPC | ❌ |
|
||||
| Dapr Workflow | ❌ |
|
||||
|
||||
## Running the Demo
|
||||
|
||||
Before running this Demo, make sure to configure Dapr for local development following the links above. The Dapr containers must be running for this demo application to run.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Kickoff --> A
|
||||
Kickoff --> B
|
||||
A --> C
|
||||
B --> C
|
||||
|
||||
C -->|Count < 3| Kickoff
|
||||
C -->|Count >= 3| End
|
||||
|
||||
classDef kickoffClass fill:#f9f,stroke:#333,stroke-width:2px;
|
||||
class Kickoff kickoffClass;
|
||||
|
||||
End((End))
|
||||
```
|
||||
|
||||
1. Build and run the sample. Running the Dapr service locally can be done using the Dapr Cli or with the Dapr VS Code extension. The VS Code extension is the recommended approach if you want to debug the code as it runs.
|
||||
- If using VSCode to debug, select either the `Python FastAPI App with Dapr` or the `Python Flask API App with Dapr` option from the Run and Debug dropdown list.
|
||||
1. When the service is up and running, it will expose a single API in localhost port 5001.
|
||||
|
||||
#### Invoking the process:
|
||||
|
||||
1. Open a web browser and point it to [http://localhost:5001/processes/1234](http://localhost:5001/processes/1234) to invoke a new process with `Id = "1234"`
|
||||
1. When the process is complete, you should see `{"processId":"1234"}` in the web browser.
|
||||
1. You should also see console output from the running service with logs that match the following:
|
||||
|
||||
```text
|
||||
##### Kickoff ran.
|
||||
##### AStep ran.
|
||||
##### BStep ran.
|
||||
##### CStep activated with Cycle = '1'.
|
||||
##### CStep run cycle 2.
|
||||
##### Kickoff ran.
|
||||
##### AStep ran.
|
||||
##### BStep ran.
|
||||
##### CStep run cycle 3 - exiting.
|
||||
```
|
||||
|
||||
Now refresh the page in your browser to run the same processes instance again. Now the logs should look like this:
|
||||
|
||||
```text
|
||||
##### Kickoff ran.
|
||||
##### AStep ran.
|
||||
##### BStep ran.
|
||||
##### CStep run cycle 4 - exiting.
|
||||
```
|
||||
|
||||
Notice that the logs from the two runs are not the same. In the first run, the processes has not been run before and so it's initial
|
||||
state came from what we defined in the process:
|
||||
|
||||
**_First Run_**
|
||||
|
||||
- `CState` is initialized with `Cycle = 1` which is the initial state that we specified while building the process.
|
||||
- `CState` is invoked a total of two times before the terminal condition of `Cycle >= 3` is reached.
|
||||
|
||||
In the second run however, the process has persisted state from the first run:
|
||||
|
||||
**_Second Run_**
|
||||
|
||||
- `CState` is initialized with `Cycle = 3` which is the final state from the first run of the process.
|
||||
- `CState` is invoked only once and is already in the terminal condition of `Cycle >= 3`.
|
||||
|
||||
If you create a new instance of the process with `Id = "ABCD"` by pointing your browser to [http://localhost:5001/processes/ABCD](http://localhost:5001/processes/ABCD), you will see the it will start with the initial state as expected.
|
||||
|
||||
## Understanding the Code
|
||||
|
||||
Below are the key aspects of the code that show how Dapr and Semantic Kernel Processes can be integrated into a FastAPI app:
|
||||
|
||||
- Create a new Dapr FastAPI app.
|
||||
- Add the required Semantic Kernel and Dapr packages to your project:
|
||||
|
||||
**_General Imports and Dapr Packages_**
|
||||
|
||||
**_FastAPI App_**
|
||||
|
||||
```python
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from dapr.ext.fastapi import DaprActor
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
```
|
||||
|
||||
**_Flask API App_**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_dapr.actor import DaprActor
|
||||
```
|
||||
|
||||
**_Semantic Kernel Process Imports_**
|
||||
|
||||
```python
|
||||
from samples.demos.process_with_dapr.process.process import get_process
|
||||
from samples.demos.process_with_dapr.process.steps import CommonEvents
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.processes.dapr_runtime import (
|
||||
register_fastapi_dapr_actors,
|
||||
start,
|
||||
)
|
||||
```
|
||||
|
||||
**_Define the FastAPI app, Dapr App, and the DaprActor_**
|
||||
|
||||
```python
|
||||
# Define the kernel that is used throughout the process
|
||||
kernel = Kernel()
|
||||
|
||||
|
||||
# Define a lifespan method that registers the actors with the Dapr runtime
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("## actor startup ##")
|
||||
await register_fastapi_dapr_actors(actor, kernel)
|
||||
yield
|
||||
|
||||
|
||||
# Define the FastAPI app along with the DaprActor
|
||||
app = FastAPI(title="SKProcess", lifespan=lifespan)
|
||||
actor = DaprActor(app)
|
||||
```
|
||||
|
||||
If using Flask, you will define:
|
||||
|
||||
```python
|
||||
kernel = Kernel()
|
||||
|
||||
app = Flask("SKProcess")
|
||||
|
||||
# Enable DaprActor Flask extension
|
||||
actor = DaprActor(app)
|
||||
|
||||
# Synchronously register actors
|
||||
print("## actor startup ##")
|
||||
register_flask_dapr_actors(actor, kernel)
|
||||
|
||||
# Create the global event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
```
|
||||
|
||||
- Build and run a Process as you normally would. For this Demo we run a simple example process from with either a FastAPI or a Flask API method in response to a GET request.
|
||||
- [See the FastAPI app here](./fastapi_app.py).
|
||||
- [See the Flask API app here](./flask_app.py)
|
||||
@@ -0,0 +1,85 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from dapr.ext.fastapi import DaprActor
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from samples.demos.process_with_dapr.process.process import get_process
|
||||
from samples.demos.process_with_dapr.process.steps import CommonEvents, CStepState
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.processes.dapr_runtime import register_fastapi_dapr_actors, start
|
||||
from semantic_kernel.processes.dapr_runtime.dapr_kernel_process_context import DaprKernelProcessContext
|
||||
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
|
||||
# Define the kernel that is used throughout the process
|
||||
kernel = Kernel()
|
||||
|
||||
"""
|
||||
The following Process and Dapr runtime sample uses a FastAPI app
|
||||
to start a process and run steps. The process is defined in the
|
||||
process/process.py file and the steps are defined in the steps.py
|
||||
file. The process is started by calling the /processes/{process_id}
|
||||
endpoint. The actors are registered with the Dapr runtime using
|
||||
the DaprActor class. The ProcessActor and the StepActor require a
|
||||
kernel dependency to be injected during creation. This is done by
|
||||
defining a factory function that takes the kernel as a parameter
|
||||
and returns the actor instance with the kernel injected.
|
||||
"""
|
||||
|
||||
# Get the process which means we have the `KernelProcess` object
|
||||
# along with any defined step factories
|
||||
process = get_process()
|
||||
|
||||
|
||||
# Define a lifespan method that registers the actors with the Dapr runtime
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("## actor startup ##")
|
||||
await register_fastapi_dapr_actors(actor, kernel, process.factories)
|
||||
yield
|
||||
|
||||
|
||||
# Define the FastAPI app along with the DaprActor
|
||||
app = FastAPI(title="SKProcess", lifespan=lifespan)
|
||||
actor = DaprActor(app)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthcheck():
|
||||
return "Healthy!"
|
||||
|
||||
|
||||
@app.get("/processes/{process_id}")
|
||||
async def start_process(process_id: str):
|
||||
try:
|
||||
context: DaprKernelProcessContext = await start(
|
||||
process=process,
|
||||
kernel=kernel,
|
||||
initial_event=CommonEvents.StartProcess,
|
||||
process_id=process_id,
|
||||
)
|
||||
kernel_process = await context.get_state()
|
||||
|
||||
# If desired, uncomment the following lines to see the process state
|
||||
# final_state = kernel_process.to_process_state_metadata()
|
||||
# print(final_state.model_dump(exclude_none=True, by_alias=True, mode="json"))
|
||||
|
||||
c_step_state: KernelProcessStepState[CStepState] = next(
|
||||
(s.state for s in kernel_process.steps if s.state.name == "CStep"), None
|
||||
)
|
||||
c_step_state_validated = CStepState.model_validate(c_step_state.state)
|
||||
print(f"[FINAL STEP STATE]: CStepState current cycle: {c_step_state_validated.current_cycle}")
|
||||
return JSONResponse(content={"processId": process_id}, status_code=200)
|
||||
except Exception:
|
||||
return JSONResponse(content={"error": "Error starting process"}, status_code=500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=5001, log_level="error") # nosec
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_dapr.actor import DaprActor
|
||||
|
||||
from samples.demos.process_with_dapr.process.process import get_process
|
||||
from samples.demos.process_with_dapr.process.steps import CommonEvents
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.processes.dapr_runtime import (
|
||||
register_flask_dapr_actors,
|
||||
start,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
|
||||
# Define the kernel that is used throughout the process
|
||||
kernel = Kernel()
|
||||
|
||||
app = Flask("SKProcess")
|
||||
|
||||
# Enable DaprActor Flask extension
|
||||
actor = DaprActor(app)
|
||||
|
||||
# Synchronously register actors
|
||||
print("## actor startup ##")
|
||||
register_flask_dapr_actors(actor, kernel)
|
||||
|
||||
# Create the global event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
|
||||
@app.route("/healthz", methods=["GET"])
|
||||
def healthcheck():
|
||||
return "Healthy!", 200
|
||||
|
||||
|
||||
@app.route("/processes/<process_id>", methods=["GET"])
|
||||
def start_process(process_id):
|
||||
try:
|
||||
process = get_process()
|
||||
|
||||
# Run the start coroutine in a synchronous manner
|
||||
asyncio.set_event_loop(loop)
|
||||
_ = asyncio.run(
|
||||
start(
|
||||
process=process,
|
||||
kernel=kernel,
|
||||
initial_event=CommonEvents.StartProcess,
|
||||
process_id=process_id,
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({"processId": process_id}), 200
|
||||
except Exception:
|
||||
return jsonify({"error": "Error starting process"}), 500
|
||||
|
||||
|
||||
# Run application
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5001) # nosec
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from samples.demos.process_with_dapr.process.process import get_process
|
||||
from samples.demos.process_with_dapr.process.steps import AStep, BStep, CStep, KickOffStep
|
||||
|
||||
__all__ = ["AStep", "BStep", "KickOffStep", "CStep", "get_process"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from samples.demos.process_with_dapr.process.steps import (
|
||||
AStep,
|
||||
BStep,
|
||||
CommonEvents,
|
||||
CStep,
|
||||
CStepState,
|
||||
KickOffStep,
|
||||
bstep_factory,
|
||||
)
|
||||
from semantic_kernel.processes import ProcessBuilder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
|
||||
|
||||
|
||||
def get_process() -> "KernelProcess":
|
||||
# Define the process builder
|
||||
process = ProcessBuilder(name="ProcessWithDapr")
|
||||
|
||||
# Add the step types to the builder
|
||||
kickoff_step = process.add_step(step_type=KickOffStep)
|
||||
myAStep = process.add_step(step_type=AStep)
|
||||
myBStep = process.add_step(step_type=BStep, factory_function=bstep_factory)
|
||||
|
||||
# Initialize the CStep with an initial state and the state's current cycle set to 1
|
||||
myCStep = process.add_step(step_type=CStep, initial_state=CStepState(current_cycle=1))
|
||||
|
||||
# Define the input event and where to send it to
|
||||
process.on_input_event(event_id=CommonEvents.StartProcess).send_event_to(target=kickoff_step)
|
||||
|
||||
# Define the process flow
|
||||
kickoff_step.on_event(event_id=CommonEvents.StartARequested).send_event_to(target=myAStep)
|
||||
kickoff_step.on_event(event_id=CommonEvents.StartBRequested).send_event_to(target=myBStep)
|
||||
myAStep.on_event(event_id=CommonEvents.AStepDone).send_event_to(target=myCStep, parameter_name="astepdata")
|
||||
|
||||
# Define the fan in behavior once both AStep and BStep are done
|
||||
myBStep.on_event(event_id=CommonEvents.BStepDone).send_event_to(target=myCStep, parameter_name="bstepdata")
|
||||
myCStep.on_event(event_id=CommonEvents.CStepDone).send_event_to(target=kickoff_step)
|
||||
myCStep.on_event(event_id=CommonEvents.ExitRequested).stop_process()
|
||||
|
||||
# Build the process
|
||||
return process.build()
|
||||
@@ -0,0 +1,119 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
|
||||
from azure.identity import AzureCliCredential
|
||||
from pydantic import Field
|
||||
|
||||
from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread
|
||||
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
|
||||
from semantic_kernel.functions import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
from semantic_kernel.processes.kernel_process import (
|
||||
KernelProcessStep,
|
||||
KernelProcessStepContext,
|
||||
KernelProcessStepState,
|
||||
)
|
||||
|
||||
|
||||
class CommonEvents(Enum):
|
||||
"""Common events for the sample process."""
|
||||
|
||||
UserInputReceived = "UserInputReceived"
|
||||
CompletionResponseGenerated = "CompletionResponseGenerated"
|
||||
WelcomeDone = "WelcomeDone"
|
||||
AStepDone = "AStepDone"
|
||||
BStepDone = "BStepDone"
|
||||
CStepDone = "CStepDone"
|
||||
StartARequested = "StartARequested"
|
||||
StartBRequested = "StartBRequested"
|
||||
ExitRequested = "ExitRequested"
|
||||
StartProcess = "StartProcess"
|
||||
|
||||
|
||||
# Define a sample step that once the `on_input_event` is received,
|
||||
# it will emit two events to start the A and B steps.
|
||||
class KickOffStep(KernelProcessStep):
|
||||
KICK_OFF_FUNCTION: ClassVar[str] = "kick_off"
|
||||
|
||||
@kernel_function(name=KICK_OFF_FUNCTION)
|
||||
async def print_welcome_message(self, context: KernelProcessStepContext):
|
||||
print("##### Kickoff ran.")
|
||||
await context.emit_event(process_event=CommonEvents.StartARequested, data="Get Going A")
|
||||
await context.emit_event(process_event=CommonEvents.StartBRequested, data="Get Going B")
|
||||
|
||||
|
||||
# Define a sample `AStep` step that will emit an event after 1 second.
|
||||
# The event will be sent to the `CStep` step with the data `I did A`.
|
||||
class AStep(KernelProcessStep):
|
||||
@kernel_function()
|
||||
async def do_it(self, context: KernelProcessStepContext):
|
||||
print("##### AStep ran.")
|
||||
await asyncio.sleep(1)
|
||||
await context.emit_event(process_event=CommonEvents.AStepDone, data="I did A")
|
||||
|
||||
|
||||
# Define a simple factory for the BStep that can create the dependency that the BStep requires
|
||||
# As an example, this factory creates a kernel and adds an `AzureChatCompletion` service to it.
|
||||
async def bstep_factory():
|
||||
"""Creates a BStep instance with ephemeral references like ChatCompletionAgent."""
|
||||
agent = ChatCompletionAgent(
|
||||
service=AzureChatCompletion(credential=AzureCliCredential()), name="echo", instructions="repeat the input back"
|
||||
)
|
||||
step_instance = BStep()
|
||||
step_instance.agent = agent
|
||||
step_instance.thread = ChatHistoryAgentThread()
|
||||
|
||||
return step_instance
|
||||
|
||||
|
||||
class BStep(KernelProcessStep):
|
||||
"""A sample BStep that optionally holds a ChatCompletionAgent.
|
||||
|
||||
By design, the agent is ephemeral (not stored in state).
|
||||
"""
|
||||
|
||||
# Ephemeral references won't be persisted to Dapr
|
||||
# because we do not place them in a step state model.
|
||||
# We'll set this in the factory function:
|
||||
agent: ChatCompletionAgent | None = None
|
||||
thread: ChatHistoryAgentThread | None = None
|
||||
|
||||
@kernel_function(name="do_it")
|
||||
async def do_it(self, context: KernelProcessStepContext):
|
||||
print("##### BStep ran (do_it).")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if self.agent:
|
||||
response = await self.agent.get_response(messages="Hello from BStep!")
|
||||
print(f"BStep got agent response: {response.content}")
|
||||
|
||||
await context.emit_event(process_event="BStepDone", data="I did B")
|
||||
|
||||
|
||||
# Define a sample `CStepState` that will keep track of the current cycle.
|
||||
class CStepState(KernelBaseModel):
|
||||
current_cycle: int = 1
|
||||
|
||||
|
||||
# Define a sample `CStep` step that will emit an `ExitRequested` event after 3 cycles.
|
||||
class CStep(KernelProcessStep[CStepState]):
|
||||
state: CStepState = Field(default_factory=CStepState)
|
||||
|
||||
# The activate method overrides the base class method to set the state in the step.
|
||||
async def activate(self, state: KernelProcessStepState[CStepState]):
|
||||
"""Activates the step and sets the state."""
|
||||
self.state = state.state
|
||||
print(f"##### CStep activated with Cycle = '{self.state.current_cycle}'.")
|
||||
|
||||
@kernel_function()
|
||||
async def do_it(self, context: KernelProcessStepContext, astepdata: str, bstepdata: str):
|
||||
self.state.current_cycle += 1
|
||||
if self.state.current_cycle >= 3:
|
||||
print("##### CStep run cycle 3 - exiting.")
|
||||
await context.emit_event(process_event=CommonEvents.ExitRequested)
|
||||
return
|
||||
print(f"##### CStep run cycle {self.state.current_cycle}")
|
||||
await context.emit_event(process_event=CommonEvents.CStepDone)
|
||||
Reference in New Issue
Block a user