Populate Conversation.messages from list[Message] trigger

When Workflow.as_agent() is invoked with a list[Message], the start executor now populates Conversation.messages / Conversation.history / System.conversations.{id}.messages with prior turns only (excluding the latest user message), and surfaces the latest user message via Inputs.input and System.LastMessage*. This matches InvokeAzureAgent's contract that the messages binding holds prior turns and the executor itself appends the new user input before invoking, avoiding double-append of the trailing user turn while preserving full history (incl. assistant/system/tool roles and multi-modal content) for downstream actions.
This commit is contained in:
alliscode
2026-04-27 14:10:03 -07:00
Unverified
parent 910172c456
commit 56ab7df874
@@ -874,9 +874,15 @@ class DeclarativeActionExecutor(Executor):
Follows .NET's DefaultTransform pattern - accepts any input type:
- dict/Mapping: Used directly as workflow.inputs
- str: Converted to {"input": value}
- list[Message]: Joined to a string from the last user message text
(or last message text if no user message). Falls through to the
string-input path so System.LastMessage.Text is populated.
- list[Message]: Treated as the agent-facing message contract
(e.g. from WorkflowAgent / as_agent()). The full message list is
stored in ``Conversation.messages``/``Conversation.history`` and
mirrored to ``System.conversations.{id}.messages`` so workflows
that reference ``=Conversation.messages`` (e.g. InvokeAzureAgent)
see the complete history including assistant turns and non-text
content. The last user message's text is also used as the string
input (``Inputs.input``) and surfaced via ``System.LastMessage*``
for backward compatibility with simple text-only workflows.
- DeclarativeMessage: Internal message, no initialization needed
- Any other type: Converted via str() to {"input": str(value)}
@@ -893,22 +899,76 @@ class DeclarativeActionExecutor(Executor):
# Structured inputs - use directly
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()) - extract the
# last user message text and treat it as the string input. Fall
# through to the same state initialization as the str case so
# =System.LastMessage.Text / =System.LastMessageText keep working.
# 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)
user_text = ""
for msg in reversed(messages_list):
if str(msg.role).lower() == "user" and msg.text:
user_text = msg.text
# 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.
last_user_index = -1
for idx in range(len(messages_list) - 1, -1, -1):
if str(messages_list[idx].role).lower() == "user":
last_user_index = idx
break
if not user_text:
# Fallback: concatenate any text from the last message.
user_text = messages_list[-1].text if messages_list else ""
state.initialize({"input": user_text})
state.set("System.LastMessage", {"Text": user_text, "Id": ""})
state.set("System.LastMessageText", user_text)
if last_user_index >= 0:
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 ""
)
# 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"
for msg in history_messages:
state.append(conv_path, msg)
# 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)
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