From 65bb03b54817082bcd41f09dfdff743ade793186 Mon Sep 17 00:00:00 2001 From: Shivani Bhandari Date: Thu, 27 Aug 2026 15:55:14 +0530 Subject: [PATCH 1/2] Python: Include InvokeAzureAgent input.arguments in agent text Fold evaluated input.arguments into the text passed to agent.run() so argument-only InvokeAzureAgent actions no longer silently invoke with an empty string. --- .../_workflows/_executors_agents.py | 82 ++++++++++--------- .../declarative/tests/test_graph_coverage.py | 72 ++++++++++++++++ 2 files changed, 117 insertions(+), 37 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index 458b2b714a4..025da03eb87 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -682,6 +682,12 @@ async def _get_conversation_messages_path( async def _build_input_text(self, state: Any, arguments: dict[str, Any], messages_expr: Any) -> str: """Build input text from arguments and messages. + ``input.arguments`` are formatted as ``key: value`` lines (same shape as the + multi-value ``Workflow.Inputs`` fallback) and included in the text sent to + ``agent.run()``. Python's agent ``run()`` has no separate structured-inputs + channel, so arguments must be folded into this text rather than discarded + (#7902). + Args: state: Workflow state for expression evaluation arguments: Input arguments to evaluate @@ -690,55 +696,57 @@ async def _build_input_text(self, state: Any, arguments: dict[str, Any], message Returns: Input text for the agent """ - # Evaluate arguments evaluated_args: dict[str, Any] = {} for key, value in arguments.items(): evaluated_args[key] = state.eval_if_expression(value) + args_text = "\n".join(f"{k}: {v}" for k, v in evaluated_args.items()) if evaluated_args else "" - # Evaluate messages/input + messages_text = "" if messages_expr: evaluated_input: Any = state.eval_if_expression(messages_expr) if isinstance(evaluated_input, str): - return evaluated_input - if isinstance(evaluated_input, list) and evaluated_input: + messages_text = evaluated_input + elif isinstance(evaluated_input, list) and evaluated_input: # Extract text from last message last: Any = evaluated_input[-1] # type: ignore if isinstance(last, str): - return last - if isinstance(last, dict): + messages_text = last + elif isinstance(last, dict): last_dict = cast(dict[str, Any], last) content_val: Any = last_dict.get("content", last_dict.get("text", "")) - return str(content_val) if content_val else "" - if last is not None and hasattr(last, "text"): # type: ignore - return str(getattr(last, "text", "")) # type: ignore - if evaluated_input: - return str(cast(Any, evaluated_input)) - return "" - - # Fallback chain for implicit input (like .NET conversationId pattern): - # 1. Local.input / Local.userInput (explicit turn state) - # 2. System.LastMessage.Text (previous agent's response) - # 3. Workflow.Inputs (first agent gets workflow inputs) - input_text: str = str(state.get("Local.input") or state.get("Local.userInput") or "") - if not input_text: - # Try System.LastMessage.Text (used by external loop and agent chaining) - last_message: Any = state.get("System.LastMessage") - if isinstance(last_message, dict): - last_msg_dict = cast(dict[str, Any], last_message) - text_val: Any = last_msg_dict.get("Text", "") - input_text = str(text_val) if text_val else "" - if not input_text: - # Fall back to workflow inputs (for first agent in chain) - inputs: Any = state.get("Workflow.Inputs") - if isinstance(inputs, dict): - inputs_dict = cast(dict[str, Any], inputs) - # If single input, use its value directly - if len(inputs_dict) == 1: - input_text = str(next(iter(inputs_dict.values()))) - else: - # Multiple inputs - format as key: value pairs - input_text = "\n".join(f"{k}: {v}" for k, v in inputs_dict.items()) - return input_text if input_text else "" + messages_text = str(content_val) if content_val else "" + elif last is not None and hasattr(last, "text"): # type: ignore + messages_text = str(getattr(last, "text", "")) # type: ignore + elif evaluated_input: + messages_text = str(cast(Any, evaluated_input)) + else: + # Fallback chain for implicit input (like .NET conversationId pattern): + # 1. Local.input / Local.userInput (explicit turn state) + # 2. System.LastMessage.Text (previous agent's response) + # 3. Workflow.Inputs (first agent gets workflow inputs) + messages_text = str(state.get("Local.input") or state.get("Local.userInput") or "") + if not messages_text: + # Try System.LastMessage.Text (used by external loop and agent chaining) + last_message: Any = state.get("System.LastMessage") + if isinstance(last_message, dict): + last_msg_dict = cast(dict[str, Any], last_message) + text_val: Any = last_msg_dict.get("Text", "") + messages_text = str(text_val) if text_val else "" + if not messages_text: + # Fall back to workflow inputs (for first agent in chain) + inputs: Any = state.get("Workflow.Inputs") + if isinstance(inputs, dict): + inputs_dict = cast(dict[str, Any], inputs) + # If single input, use its value directly + if len(inputs_dict) == 1: + messages_text = str(next(iter(inputs_dict.values()))) + else: + # Multiple inputs - format as key: value pairs + messages_text = "\n".join(f"{k}: {v}" for k, v in inputs_dict.items()) + + if args_text and messages_text: + return f"{args_text}\n{messages_text}" + return args_text or messages_text or "" def _get_agent(self, agent_name: str, ctx: WorkflowContext[Any, Any]) -> Any: """Get agent from registry (sync helper for response handler).""" diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index fe4c6e7060a..29979b93177 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -977,6 +977,78 @@ async def test_agent_executor_build_input_text_fallback_chain(self, mock_context input_text = await executor._build_input_text(state, {}, None) assert input_text == "workflow input" + async def test_agent_executor_build_input_text_includes_arguments_only(self, mock_context, mock_state): + """Regression for #7902: input.arguments must reach the agent when messages are omitted.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text( + state, + { + "IssueDescription": "The printer on the 3rd floor is jammed.", + "AttemptedResolutionSteps": "Restarted the printer twice.", + }, + None, + ) + + assert "IssueDescription: The printer on the 3rd floor is jammed." in input_text + assert "AttemptedResolutionSteps: Restarted the printer twice." in input_text + + @_requires_powerfx + async def test_agent_executor_build_input_text_combines_arguments_and_messages( + self, mock_context, mock_state + ): + """input.arguments are kept alongside explicit messages (#7902).""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + state.set("Local.userInput", "Please help with this ticket.") + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text( + state, + {"IssueDescription": "Printer jammed"}, + "=Local.userInput", + ) + + assert input_text == "IssueDescription: Printer jammed\nPlease help with this ticket." + + @_requires_powerfx + async def test_agent_executor_build_input_text_evaluates_argument_expressions( + self, mock_context, mock_state + ): + """Argument values that are expressions are evaluated before formatting (#7902).""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + state.set("Local.issue", "Network outage") + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text( + state, + {"IssueDescription": "=Local.issue"}, + None, + ) + + assert input_text == "IssueDescription: Network outage" + async def test_agent_executor_build_input_text_from_system_last_message(self, mock_context, mock_state): """Test _build_input_text falls back to system.LastMessage.Text.""" from agent_framework_declarative._workflows._executors_agents import ( From 4915e69c5db319ba719f2678f0d7c2f03195868f Mon Sep 17 00:00:00 2001 From: Shivani Bhandari Date: Thu, 10 Sep 2026 00:33:49 +0530 Subject: [PATCH 2/2] Python: skip implicit fallback when InvokeAzureAgent arguments are present Addresses review on #7907: arguments-only actions no longer append System.LastMessage / Workflow.Inputs fallback text to their structured fields in chained workflows. Adds a regression test with poisoned fallback state. --- .../_workflows/_executors_agents.py | 6 ++++- .../declarative/tests/test_graph_coverage.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index 025da03eb87..247f4a10856 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -719,8 +719,12 @@ async def _build_input_text(self, state: Any, arguments: dict[str, Any], message messages_text = str(getattr(last, "text", "")) # type: ignore elif evaluated_input: messages_text = str(cast(Any, evaluated_input)) - else: + elif not evaluated_args: # Fallback chain for implicit input (like .NET conversationId pattern): + # Only when neither explicit messages nor explicit arguments are present. + # Otherwise arguments-only actions (e.g. customer-support TicketingAgent) + # would append System.LastMessage (prior agent's response) or + # Workflow.Inputs to their structured fields in chained workflows. # 1. Local.input / Local.userInput (explicit turn state) # 2. System.LastMessage.Text (previous agent's response) # 3. Workflow.Inputs (first agent gets workflow inputs) diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index 29979b93177..2828d4e5fb7 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -977,6 +977,30 @@ async def test_agent_executor_build_input_text_fallback_chain(self, mock_context input_text = await executor._build_input_text(state, {}, None) assert input_text == "workflow input" + async def test_agent_executor_build_input_text_arguments_only_skips_implicit_fallback( + self, mock_context, mock_state + ): + """Arguments-only actions must not append implicit fallback input (#7902 review).""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_state) + state.initialize({"query": "workflow input"}) + state.set("System.LastMessage", {"Text": "prior agent response", "Id": "1"}) + state.set("Local.input", "turn input") + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text( + state, + {"IssueDescription": "Printer jammed"}, + None, + ) + + assert input_text == "IssueDescription: Printer jammed" + async def test_agent_executor_build_input_text_includes_arguments_only(self, mock_context, mock_state): """Regression for #7902: input.arguments must reach the agent when messages are omitted.""" from agent_framework_declarative._workflows._executors_agents import (