Repository navigation
Description
Description
stop_process() does nothing in the Python process framework. A process that has a stop_process() edge keeps running supersteps after the stop event fires. It only ends when no messages are left, or at the superstep limit.
There are three problems that add up to this:
ProcessStepEdgeBuilder.stop_processlinks the edge under the key"END". It should use the id of the event it was set on. No step ever emits an event calledEND, so the edge never fires.- The end step gets a random id from
ProcessStepBuilder.__init__. The runtimes look forEND_PROCESS_ID. The class has anEND_STEP_IDconstant, but nothing uses it. - In
LocalProcess.internal_execute, an end message only doesbreakout of the innerfor message in messages_to_processloop. The outer superstep loop goes on.
Reproduction
I ran this on current main (df1cb33), Python 3.13. The checker step sends work to a worker and signals Done on every run. The worker sends the result back to the checker, so the process loops. Done is wired to stop_process().
import asyncio
from semantic_kernel import Kernel
from semantic_kernel.functions import kernel_function
from semantic_kernel.processes.const import END_PROCESS_ID
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.local_runtime.local_event import KernelProcessEvent
from semantic_kernel.processes.local_runtime.local_kernel_process import start
from semantic_kernel.processes.process_builder import ProcessBuilder
calls = []
class Checker(KernelProcessStep):
@kernel_function
async def check(self, value: str, context: KernelProcessStepContext) -> None:
calls.append("check")
await context.emit_event("Work", data=value)
await context.emit_event("Done", data=None)
class Worker(KernelProcessStep):
@kernel_function
async def work(self, value: str) -> str:
calls.append("work")
return value
builder = ProcessBuilder(name="stopper")
checker = builder.add_step(Checker)
worker = builder.add_step(Worker)
builder.on_input_event("go").send_event_to(checker, function_name="check", parameter_name="value")
checker.on_event("Work").send_event_to(worker, function_name="work", parameter_name="value")
checker.on_event("Done").stop_process()
worker.on_function_result("work").send_event_to(checker, function_name="check", parameter_name="value")
process = builder.build()
checker_info = next(step for step in process.steps if step.state.name == "Checker")
for event_id, edges in checker_info.output_edges.items():
for edge in edges:
print("edge key:", event_id, "-> function", edge.output_target.function_name, "step_id", edge.output_target.step_id)
print("END_PROCESS_ID:", END_PROCESS_ID)
asyncio.run(start(process=process, kernel=Kernel(), initial_event=KernelProcessEvent(id="go", data="x")))
print("check calls:", calls.count("check"), "work calls:", calls.count("work"))Output on main:
edge key: Checker_f392ac242d084276b01b79a80495429a.Work -> function work step_id f0cbe097573448c6badbc9e93d967e22
edge key: END -> function END step_id 381c1ac4a9d14f6b9d8ee1c251d9268a
END_PROCESS_ID: Microsoft.SemanticKernel.Process.EndStep
check calls: 50 work calls: 50
The Done event has no edge. The stop edge sits under END, and its step_id is a random id, not END_PROCESS_ID. The loop runs until the default limit of 100 supersteps.
Expected
The process finishes the superstep in which the stop event fired, and then ends. Here that is one check call and one work call.
Cause
process_step_edge_builder.py has self.source.link_to("END", self). The existing unit test test_stop_process asserts that exact call, so it passes with the wrong key.
Fixing only one of the three is not enough. I patched them one at a time on main and ran the script above:
- With the right key only, the run fails with
RuntimeError: coroutine raised StopIteration. The message has a randomdestination_id, somessage.destination_id == END_PROCESS_IDis false. Thennext(step for step in self.steps if step.id == message.destination_id)finds no step. - With the right key and the fixed end step id, the run still prints
check calls: 50 work calls: 50. Thebreakonly leaves the inner message loop.
The Dapr runtime already expects the fixed id. ProcessActor._is_end_message_sent looks at the message buffer of ActorId(END_PROCESS_ID) at the start of every superstep. I could not run the Dapr runtime, so I did not test it.
Possible fix
I have a branch ready: https://github.com/VANDRANKI/semantic-kernel/tree/fix/process-stop-process-never-stops
It does three small things. stop_process links the edge under the event id. EndStep gets the fixed id END_STEP_ID, which equals END_PROCESS_ID. The local runtime remembers that an end message was sent, runs the rest of the superstep, and then leaves the loop. The same script prints check calls: 1 work calls: 1 on the branch.
It adds 4 tests in a new file, tests/unit/processes/local_runtime/test_local_process_stop.py. It also fixes test_stop_process, which asserted the wrong key. 3 of the new tests and the fixed test fail on main. The 4th new test is a control: a looping process without a stop edge keeps running up to max_supersteps. All 243 process unit tests pass on the branch.
I can't open a pull request from this account right now, so the compare link is main...VANDRANKI:semantic-kernel:fix/process-stop-process-never-stops
Activity