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:
alliscode
2026-04-28 08:49:38 -07:00
Unverified
parent dde1edffd0
commit baff7e33e1
5 changed files with 112 additions and 91 deletions
@@ -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"):