mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
feat(workflows): support combined message + checkpoint_id for multi-turn continuation
Allow Workflow.run(message=..., checkpoint_id=...) so callers can restore prior workflow state from a checkpoint AND deliver a new message to the start executor in a single call. The existing reset_context logic already preserves shared state when checkpoint_id is set, so this gives us 'fresh start executor invocation with prior state intact' - exactly what hosted multi-turn declarative workflows need. - _workflow.py: drop the message+checkpoint_id mutual exclusion and update _execute_with_message_or_checkpoint to do both (restore then execute) when both are provided. - _agent.py: in _run_core's checkpoint branch, also forward input_messages so WorkflowAgent.run(messages, checkpoint_id=...) works end-to-end. Falls back to the legacy 'restore only' behavior when messages are absent. - _declarative_base.py: detect continuation in _ensure_state_initialized by checking whether DECLARATIVE_STATE_KEY already exists in shared state; if so, refresh inputs/LastMessage* and append non-user trigger messages instead of calling state.initialize() (which would wipe Conversation/Local/System). - foundry_hosting/_responses.py: collapse the host's two-call pattern (restore-only, then fresh run) into a single combined call now that the underlying APIs support it. - tests: drop the assertion that combined message+checkpoint_id raises. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -437,8 +437,17 @@ class WorkflowAgent(BaseAgent):
|
||||
yield event
|
||||
|
||||
elif checkpoint_id is not None:
|
||||
# Restore the prior workflow state from the checkpoint and, if
|
||||
# there's a new user message in this run, deliver it to the
|
||||
# start executor in the same call. This is the multi-turn
|
||||
# continuation path: shared state (e.g. accumulated conversation
|
||||
# history maintained by the workflow's executors) survives across
|
||||
# turns because Workflow.run sets reset_context=False whenever
|
||||
# checkpoint_id is provided.
|
||||
message_arg: Any | None = list(input_messages) if input_messages else None
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
message=message_arg,
|
||||
stream=True,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
@@ -448,6 +457,7 @@ class WorkflowAgent(BaseAgent):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
message=message_arg,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
|
||||
@@ -443,7 +443,7 @@ class Workflow(DictConvertible):
|
||||
if message is None and checkpoint_id is None:
|
||||
raise ValueError("Must provide either 'message' or 'checkpoint_id'")
|
||||
|
||||
# Handle checkpoint restoration
|
||||
# Handle checkpoint restoration (may be combined with message below)
|
||||
if checkpoint_id is not None:
|
||||
has_checkpointing = self._runner.context.has_checkpointing()
|
||||
|
||||
@@ -455,8 +455,10 @@ class Workflow(DictConvertible):
|
||||
|
||||
await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
# Handle initial message
|
||||
elif message is not None:
|
||||
# Handle initial message - if combined with a checkpoint_id, this
|
||||
# delivers a continuation message to the workflow's start executor
|
||||
# without clearing prior shared state (reset_context=False).
|
||||
if message is not None:
|
||||
executor = self.get_start_executor()
|
||||
await executor.execute(
|
||||
message,
|
||||
@@ -660,7 +662,13 @@ class Workflow(DictConvertible):
|
||||
raise ValueError("Cannot provide both 'message' and 'responses'. Use one or the other.")
|
||||
|
||||
if message is not None and checkpoint_id is not None:
|
||||
raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.")
|
||||
# Combined message + checkpoint_id is supported: restore prior
|
||||
# workflow state from the checkpoint, then execute the start
|
||||
# executor with the new message. The workflow's shared state
|
||||
# (e.g. accumulated conversation history kept in custom shared
|
||||
# state) is preserved across the boundary because reset_context
|
||||
# is set to False for this combination (see _resolve_execution_mode).
|
||||
pass
|
||||
|
||||
if message is None and responses is None and checkpoint_id is None:
|
||||
raise ValueError(
|
||||
|
||||
@@ -942,14 +942,13 @@ async def test_workflow_run_parameter_validation(simple_executor: Executor) -> N
|
||||
result = await workflow.run(test_message)
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Invalid: both message and checkpoint_id
|
||||
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
|
||||
await workflow.run(test_message, checkpoint_id="fake_id")
|
||||
|
||||
# Invalid: both message and checkpoint_id (streaming)
|
||||
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
|
||||
async for _ in workflow.run(test_message, checkpoint_id="fake_id", stream=True):
|
||||
pass
|
||||
# Valid: message + checkpoint_id (combined restore + new input)
|
||||
# is supported as of the multi-turn checkpoint continuation work
|
||||
# (restore prior state, then deliver message to start executor with
|
||||
# reset_context=False). Use a fake id - we just need to confirm the
|
||||
# call no longer raises at the validation layer.
|
||||
# Note: passing a non-existent checkpoint_id will fail at restore time,
|
||||
# which is a different code path than the validation we're checking.
|
||||
|
||||
# Invalid: none of message or checkpoint_id
|
||||
with pytest.raises(ValueError, match="Must provide at least one of"):
|
||||
|
||||
Reference in New Issue
Block a user