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
@@ -914,20 +914,26 @@ class DeclarativeActionExecutor(Executor):
state.initialize(trigger) # type: ignore
elif isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger):
# list[Message] (e.g. from WorkflowAgent / as_agent()).
# Populate the full conversation rather than collapsing to a
# single string, so workflows that operate on the message list
# (InvokeAzureAgent with =Conversation.messages, history-aware
# agents, multi-modal content, etc.) see the complete input.
messages_list = cast(list[Message], trigger)
# Locate the trailing user message: WorkflowAgent merges session
# history with the caller's new input and forwards the combined
# list, so the most recent user message represents "this turn"
# (everything before it is prior history). InvokeAzureAgent's
# contract is that Conversation.messages holds PRIOR turns only -
# the executor appends the new user input itself before invoking
# the agent. To avoid duplicating the latest user turn we split
# the trigger at that boundary.
# Detect continuation: if the workflow's shared state already
# carries declarative data from a prior turn (because the host
# restored a checkpoint and dispatched this run with
# reset_context=False), we MUST NOT call state.initialize() -
# that would wipe Conversation.messages, Local.*, System.* etc.
# Instead, treat the trigger as the new turn's user input only:
# update Inputs.input, append the new user message to existing
# Conversation history, and refresh System.LastMessage*.
existing_state = state._state.get(DECLARATIVE_STATE_KEY)
# Continuation = declarative state already exists in the workflow's
# shared state (either left over in-memory from a prior turn on
# the same instance, or restored from a checkpoint just before
# this run). In that case state.initialize() would wipe Local.*,
# System.*, Conversation.* etc., destroying the cross-turn
# context we're trying to preserve.
is_continuation = existing_state is not None and isinstance(existing_state, dict)
# Locate the trailing user message in the trigger.
last_user_index = -1
for idx in range(len(messages_list) - 1, -1, -1):
if str(messages_list[idx].role).lower() == "user":
@@ -938,51 +944,59 @@ class DeclarativeActionExecutor(Executor):
last_user_msg = messages_list[last_user_index]
last_user_text = last_user_msg.text or ""
last_user_id = getattr(last_user_msg, "message_id", "") or ""
# Prior history excludes the latest user turn; trailing
# non-user messages (e.g. tool results) are preserved so
# later actions still see them in Conversation.messages.
history_messages = (
messages_list[:last_user_index] + messages_list[last_user_index + 1:]
)
else:
# No user message in the list - rare path (e.g. resume after
# an assistant-only sequence). Treat the whole list as prior
# history and surface the last message's text for backwards
# compatibility with =System.LastMessageText.
history_messages = list(messages_list)
tail = messages_list[-1] if messages_list else None
last_user_text = (tail.text or "") if tail is not None else ""
last_user_id = (
getattr(tail, "message_id", "") or "" if tail is not None else ""
)
last_user_msg = tail
# Initialize state. Using the last user text as Inputs.input
# keeps simple yamls (=inputs.input / =System.LastMessageText)
# working, and matches what InvokeAzureAgent expects to find via
# its input_text fallback chain.
state.initialize({"input": last_user_text})
# Populate Conversation.messages/.history with PRIOR turns only
# (matching the executor contract above). Raw Message objects
# are stored - matching what agent executors append at runtime.
for msg in history_messages:
state.append("Conversation.messages", msg)
state.append("Conversation.history", msg)
# Mirror to System.conversations.{ConversationId}.messages so
# actions resolving conversation-scoped paths see the same
# history.
conversation_id = state.get("System.ConversationId")
if conversation_id:
conv_path = f"System.conversations.{conversation_id}.messages"
if is_continuation:
# Continuation turn: keep prior Conversation.messages intact.
# Refresh inputs and surface the new user message via the
# System.LastMessage* fields. We deliberately do NOT append
# the new user message to Conversation.messages here: agent
# executors append the live user input themselves before
# invoking the inner agent (matching the first-turn
# contract where Conversation.messages holds prior turns
# only).
state.set("Inputs.input", last_user_text)
# Trailing non-user messages (e.g. tool results) sandwiched
# before the new user message in the trigger are still
# appended so later actions see them.
for msg in history_messages:
state.append(conv_path, msg)
state.append("Conversation.messages", msg)
state.append("Conversation.history", msg)
conversation_id = state.get("System.ConversationId")
if conversation_id:
conv_path = f"System.conversations.{conversation_id}.messages"
for msg in history_messages:
state.append(conv_path, msg)
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
state.set("System.LastMessageText", last_user_text)
state.set("System.LastMessageId", last_user_id)
else:
# First turn: full initialization.
state.initialize({"input": last_user_text})
# System.LastMessage* mirrors the most recent USER message
# (matching .NET DefaultTransform semantics for agent input).
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
state.set("System.LastMessageText", last_user_text)
state.set("System.LastMessageId", last_user_id)
for msg in history_messages:
state.append("Conversation.messages", msg)
state.append("Conversation.history", msg)
conversation_id = state.get("System.ConversationId")
if conversation_id:
conv_path = f"System.conversations.{conversation_id}.messages"
for msg in history_messages:
state.append(conv_path, msg)
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
state.set("System.LastMessageText", last_user_text)
state.set("System.LastMessageId", last_user_id)
elif isinstance(trigger, str):
# String input - wrap in dict and populate System.LastMessage.Text
# so YAML expressions like =System.LastMessage.Text see the user input