From 88e4989182d7103807cefe6a45b09c89010a7300 Mon Sep 17 00:00:00 2001 From: Shivani Bhandari Date: Thu, 27 Aug 2026 15:55:14 +0530 Subject: [PATCH] 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 190393f6510..c59058b15ae 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -675,6 +675,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 @@ -683,55 +689,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 ec83d94a1aa..c0091179eef 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -938,6 +938,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 (