mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Address Copilot review batch: tests + Workflow.reset escape hatch
* Add Workflow.reset() public method as recovery escape hatch when an in-flight run aborted (e.g. WorkflowConvergenceException) and the workflow is not checkpointed. Update the in-flight messages guard's error message to point callers at it. * Add test_workflow_run_inflight_messages_guard exercising both the guard (sync + streaming) and the reset() recovery path. * Add test_workflow_reset_rejects_concurrent_runs to lock down the in-progress guard on reset. * Add test_as_agent_continuation_preserves_prior_state covering the is_continuation branch in _ensure_state_initialized: stamps a marker between calls and asserts it survives, while Inputs.input and System.LastMessageText refresh to the new turn. * Add test_powerfx_safe.py regression tests for the Enum branch in _make_powerfx_safe (str-subclass, int-subclass, plain Enum, and Enums nested in dict/list). * Drop redundant @pytest.mark.asyncio on test_as_agent_round_trip_with_last_message_text (asyncio_mode='auto'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -618,7 +618,8 @@ class Workflow(DictConvertible):
|
||||
raise RuntimeError(
|
||||
"Cannot start a new run with 'message' while in-flight executor "
|
||||
"messages remain from a prior run. Either resume from a checkpoint "
|
||||
"(checkpoint_id=...) or wait for the prior run to complete."
|
||||
"(checkpoint_id=...), wait for the prior run to complete, or call "
|
||||
"'await workflow.reset()' to drop the pending messages."
|
||||
)
|
||||
|
||||
initial_executor_fn = self._resolve_execution_mode(
|
||||
@@ -651,6 +652,30 @@ class Workflow(DictConvertible):
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
self._reset_running_flag()
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Drop all in-flight executor messages and per-run accounting.
|
||||
|
||||
Workflows preserve shared state and pending executor messages
|
||||
across :meth:`run` calls so that multi-turn callers (e.g.
|
||||
:class:`WorkflowAgent`) can deliver follow-up turns to the same
|
||||
instance without losing context. If a prior run aborted (e.g. the
|
||||
runner raised :class:`WorkflowConvergenceException`) and the
|
||||
workflow is not checkpointed, those pending messages remain in
|
||||
the runner context and every future ``run(message=...)`` call
|
||||
fails with ``RuntimeError`` because of the in-flight-messages
|
||||
guard. Callers that have no checkpoint to resume from can use
|
||||
``await workflow.reset()`` as an explicit escape hatch to clear
|
||||
pending messages and start fresh.
|
||||
|
||||
Note: this does NOT clear the workflow ``State`` (use
|
||||
:meth:`Workflow.run` with a ``checkpoint_id`` for state replay)
|
||||
and is a no-op while another run is in progress on this instance.
|
||||
"""
|
||||
if self._is_running:
|
||||
raise RuntimeError("Cannot reset a workflow while a run is in progress.")
|
||||
self._runner.context.reset_for_new_run()
|
||||
self._runner.reset_iteration_count()
|
||||
|
||||
@staticmethod
|
||||
def _finalize_events(
|
||||
events: Sequence[WorkflowEvent],
|
||||
|
||||
@@ -953,6 +953,50 @@ async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
|
||||
|
||||
|
||||
async def test_workflow_run_inflight_messages_guard(simple_executor: Executor) -> None:
|
||||
"""``run(message=...)`` must reject in-flight executor messages from a prior run.
|
||||
|
||||
Workflows preserve state and pending messages across :meth:`Workflow.run`
|
||||
calls. If a prior run aborted before the runner drained those pending
|
||||
messages (e.g. it raised :class:`WorkflowConvergenceException`), the next
|
||||
fresh-message call should fail loudly instead of silently mixing the
|
||||
leftover messages with the new turn. Callers can recover via
|
||||
:meth:`Workflow.reset`.
|
||||
"""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
test_message = WorkflowMessage(data="test", source_id="test", target_id=None)
|
||||
|
||||
# Simulate an aborted prior run by leaving a message in the runner context.
|
||||
workflow._runner.context._messages["test"] = [test_message]
|
||||
assert await workflow._runner.context.has_messages()
|
||||
|
||||
with pytest.raises(RuntimeError, match="in-flight executor messages"):
|
||||
await workflow.run(test_message)
|
||||
|
||||
with pytest.raises(RuntimeError, match="in-flight executor messages"):
|
||||
async for _ in workflow.run(test_message, stream=True):
|
||||
pass
|
||||
|
||||
# ``Workflow.reset`` is the documented escape hatch.
|
||||
await workflow.reset()
|
||||
assert not await workflow._runner.context.has_messages()
|
||||
|
||||
# After reset, a new run is accepted again.
|
||||
result = await workflow.run(test_message)
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_reset_rejects_concurrent_runs(simple_executor: Executor) -> None:
|
||||
"""``Workflow.reset`` must not stomp on an in-progress run."""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
workflow._is_running = True
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="run is in progress"):
|
||||
await workflow.reset()
|
||||
finally:
|
||||
workflow._is_running = False
|
||||
|
||||
|
||||
async def test_workflow_run_parameter_validation(simple_executor: Executor) -> None:
|
||||
"""Test that stream properly validate parameter combinations."""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
|
||||
Reference in New Issue
Block a user